0%

大数据实时计算引擎 Flink 实战与性能优化

大数据实时计算引擎 Flink 实战与性能优化

Flink作为流处理方案的最佳选择,还有流处理 批处理大一统之势,可谓必知必会

undefined

一、公司到底需不需要引入实时计算引擎?

实时计算需求

大数据发展至今,数据呈指数倍的增长,对实效性的要求也越来越高,所以你可能接触到下面这类需求会越来越多。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
小田,你看能不能做个监控大屏实时查看促销活动销售额(GMV)?

小朱,搞促销活动的时候能不能实时统计下网站的 PV/UV 啊?

小鹏,我们现在搞促销活动能不能实时统计销量 Top5 啊?

小李,怎么回事啊?现在搞促销活动结果服务器宕机了都没告警,能不能加一个?

小刘,服务器这会好卡,是不是出了什么问题啊,你看能不能做个监控大屏实时查看机器的运行情况?

小赵,我们线上的应用频繁出现 Error 日志,但是只有靠人肉上机器查看才知道情况,能不能在出现错误的时候及时告警通知?

小夏,我们 1 元秒杀促销活动中有件商品被某个用户薅了 100 件,怎么都没有风控啊?

小宋,你看我们搞促销活动能不能根据每个顾客的浏览记录实时推荐不同的商品啊?

……

那这些场景对应着什么业务需求呢?我们来总结下,大概如下:

undefined

初看这些需求,是不是感觉很难?那么我们接下来来分析一下该怎么去实现?

从这些需求来看,最根本的业务都是需要实时查看数据信息,那么首先我们得想想如何去采集这些实时数据,然后将采集的实时数据进行实时的计算,最后将计算后的结果下发到第三方。

数据实时采集

就上面这些需求,我们需要采集些什么数据呢?

  1. 买家搜索记录信息
  2. 买家浏览的商品信息
  3. 买家下单订单信息
  4. 网站的所有浏览记录
  5. 机器 CPU/MEM/IO 信息
  6. 应用日志信息

数据实时计算

采集后的数据实时上报后,需要做实时的计算,那我们怎么实现计算呢?

  1. 计算所有商品的总销售额
  2. 统计单个商品的销量,最后求 Top5
  3. 关联用户信息和浏览信息、下单信息
  4. 统计网站所有的请求 IP 并统计每个 IP 的请求数量
  5. 计算一分钟内机器 CPU/MEM/IO 的平均值、75 分位数值
  6. 过滤出 Error 级别的日志信息

数据实时下发

实时计算后的数据,需要及时的下发到下游,这里说的下游代表可能是:

  1. 告警方式(邮件、短信、钉钉、微信)

在计算层会将计算结果与阈值进行比较,超过阈值触发告警,让运维提前收到通知,及时做好应对措施,减少故障的损失大小。

undefined

  1. 存储(消息队列、DB、文件系统等)

数据存储后,监控大盘(Dashboard)从存储(ElasticSearch、HBase 等)里面查询对应指标的数据就可以查看实时的监控信息,做到对促销活动的商品销量、销售额,机器 CPU、MEM 等有实时监控,运营、运维、开发、领导都可以实时查看并作出对应的措施。

  • 让运营知道哪些商品是爆款,哪些店铺成交额最多,哪些商品成交额最高,哪些商品浏览量最多;

undefined

  • 让运维可以时刻了解机器的运行状况,出现宕机或者其他不稳定情况可以及时处理;

undefined

undefined

  • 让开发知道自己项目运行的情况,从 Error 日志知道出现了哪些 Bug;

undefined

  • 让领导知道这次促销赚了多少 money。

undefined

从数据采集到数据计算再到数据下发,整个流程在上面的场景对实时性要求还是很高的,任何一个地方出现问题都将影响最后的效果!

undefined

实时计算场景

前面说了这么多场景,这里我们总结一下实时计算常用的场景有哪些呢?

  1. 交通信号灯数据
  2. 道路上车流量统计(拥堵状况)
  3. 公安视频监控
  4. 服务器运行状态监控
  5. 金融证券公司实时跟踪股市波动,计算风险价值
  6. 数据实时 ETL
  7. 银行或者支付公司涉及金融盗窃的预警

……

另外自己还做过调研,实时计算框架的使用场景有如下这些:

undefined

总结一下大概有下面这四类:

undefined

  1. 实时数据存储

实时数据存储的时候做一些微聚合、过滤某些字段、数据脱敏,组建数据仓库,实时 ETL。

  1. 实时数据分析

实时数据接入机器学习框架(TensorFlow)或者一些算法进行数据建模、分析,然后动态的给出商品推荐、广告推荐

  1. 实时监控告警

金融相关涉及交易、实时风控、车流量预警、服务器监控告警、应用日志告警

  1. 实时数据报表

活动营销时销售额/销售量大屏,TopN 商品

说到实时计算,这里不得不讲一下和传统的离线计算的区别!

离线计算 vs 实时计算

再讲这两个区别之前,我们先来看看流处理和批处理的区别:

流处理与批处理

undefined

看完流处理与批处理这两者的区别之后,我们来抽象一下前面文章的场景需求(实时计算):

undefined

实时计算需要不断的从 MQ 中读取采集的数据,然后处理计算后往 DB 里存储,在计算这层你无法感知到会有多少数据量过来、要做一些简单的操作(过滤、聚合等)、及时将数据下发。

相比传统的离线计算,它却是这样的:

undefined

在计算这层,它从 DB(不限 MySQL,还有其他的存储介质)里面读取数据,该数据一般就是固定的(前一天、前一星期、前一个月),然后再做一些复杂的计算或者统计分析,最后生成可供直观查看的报表(dashboard)。

离线计算的特点

  1. 数据量大且时间周期长(一天、一星期、一个月、半年、一年)
  2. 在大量数据上进行复杂的批量运算
  3. 数据在计算之前已经固定,不再会发生变化
  4. 能够方便的查询批量计算的结果

实时计算的特点

在大数据中与离线计算对应的则是实时计算,那么实时计算有什么特点呢?由于应用场景的各不相同,所以这两种计算引擎接收数据的方式也不太一样:离线计算的数据是固定的(不再会发生变化),通常离线计算的任务都是定时的,如:每天晚上 0 点的时候定时计算前一天的数据,生成报表;然而实时计算的数据源却是流式的。

这里我不得不讲讲什么是流式数据呢?我的理解是比如你在淘宝上下单了某个商品或者点击浏览了某件商品,你就会发现你的页面立马就会给你推荐这种商品的广告和类似商品的店铺,这种就是属于实时数据处理然后作出相关推荐,这类数据需要不断的从你在网页上的点击动作中获取数据,之后进行实时分析然后给出推荐。

流式数据的特点

  1. 数据实时到达
  2. 数据到达次序独立,不受应用系统所控制
  3. 数据规模大且无法预知容量
  4. 原始数据一经处理,除非特意保存,否则不能被再次取出处理,或者再次提取数据代价昂贵

undefined

实时计算的优势

实时计算一时爽,一直实时计算一直爽,对于持续生成最新数据的场景,采用流数据处理是非常有利的。例如,再监控服务器的一些运行指标的时候,能根据采集上来的实时数据进行判断,当超出一定阈值的时候发出警报,进行提醒作用。再如通过处理流数据生成简单的报告,如五分钟的窗口聚合数据平均值。复杂的事情还有在流数据中进行数据多维度关联、聚合、塞选,从而找到复杂事件中的根因。更为复杂的是做一些复杂的数据分析操作,如应用机器学习算法,然后根据算法处理后的数据结果提取出有效的信息,作出、给出不一样的推荐内容,让不同的人可以看见不同的网页(千人千面)。

实时计算面临的挑战

  1. 数据处理唯一性(如何保证数据只处理一次?至少一次?最多一次?)
  2. 数据处理的及时性(采集的实时数据量太大的话可能会导致短时间内处理不过来,如何保证数据能够及时的处理,不出现数据堆积?)
  3. 数据处理层和存储层的可扩展性(如何根据采集的实时数据量的大小提供动态扩缩容?)
  4. 数据处理层和存储层的容错性(如何保证数据处理层和存储层高可用,出现故障时数据处理层和存储层服务依旧可用?)

因为各种需求,也就造就了现在不断出现实时计算框架,在 1.2 节中将重磅介绍如今最火的实时计算框架 —— Flink,在 1.3 节中会对比介绍 Spark Streaming、Structured Streaming 和 Storm 之间的区别。

小结与反思

本节从实时计算的需求作为切入点,然后分析该如何去完成这种实时计算的需求,从而得知整个过程包括数据采集、数据计算、数据存储等,接着总结了实时计算场景的类型。最后开始介绍离线计算与实时计算的区别,并提出了实时计算可能带来的挑战。你们公司有文中所讲的类似需求吗?你是怎么解决的呢?


在 1.1 节中讲解了日常开发常见的实时需求,然后分析了这些需求的实现方式,接着对比了实时计算和离线计算。随着这些年大数据的飞速发展,也出现了不少计算的框架(Hadoop、Storm、Spark、Flink)。在网上有人将大数据计算引擎的发展分为四个阶段。

  • 第一代:Hadoop 承载的 MapReduce
  • 第二代:支持 DAG(有向无环图)框架的计算引擎 Tez 和 Oozie,主要还是批处理任务
  • 第三代:支持 Job 内部的 DAG(有向无环图),以 Spark 为代表
  • 第四代:大数据统一计算引擎,包括流处理、批处理、AI、Machine Learning、图计算等,以 Flink 为代表

或许会有人不同意以上的分类,笔者觉得其实这并不重要的,重要的是体会各个框架的差异,以及更适合的场景。并进行理解,没有哪一个框架可以完美的支持所有的场景,也就不可能有任何一个框架能完全取代另一个。

本文将对 Flink 的整体架构和 Flink 的多种特性做个详细的介绍!在讲 Flink 之前的话,我们先来看看数据集类型数据运算模型的种类。

数据集类型

  • 无穷数据集:无穷的持续集成的数据集合
  • 有界数据集:有限不会改变的数据集合

那么那些常见的无穷数据集有哪些呢?

  • 用户与客户端的实时交互数据
  • 应用实时产生的日志
  • 金融市场的实时交易记录

数据运算模型

  • 流式:只要数据一直在产生,计算就持续地进行
  • 批处理:在预先定义的时间内运行计算,当计算完成时释放计算机资源

那么我们再来看看 Flink 它是什么呢?

undefined

Flink 是一个针对流数据和批数据的分布式处理引擎,代码主要是由 Java 实现,部分代码是 Scala。它可以处理有界的批量数据集、也可以处理无界的实时数据集。对 Flink 而言,其所要处理的主要场景就是流数据,批数据只是流数据的一个极限特例而已,所以 Flink 也是一款真正的流批统一的计算引擎。

undefined

Flink 提供了 State、Checkpoint、Time、Window 等,它们为 Flink 提供了基石,本篇文章下面会稍作讲解,具体深度分析后面会有专门的文章来讲解。

undefined

从下至上:

  1. 部署:Flink 支持本地运行(IDE 中直接运行程序)、能在独立集群(Standalone 模式)或者在被 YARN、Mesos、K8s 管理的集群上运行,也能部署在云上。
  2. 运行:Flink 的核心是分布式流式数据引擎,意味着数据以一次一个事件的形式被处理。
  3. API:DataStream、DataSet、Table、SQL API。
  4. 扩展库:Flink 还包括用于 CEP(复杂事件处理)、机器学习、图形处理等场景。

undefined

作为一个计算引擎,如果要做的足够完善,除了它自身的各种特点要包含,还得支持各种生态圈,比如部署的情况,Flink 是支持以 Standalone、YARN、Kubernetes、Mesos 等形式部署的。

  • Local:直接在 IDE 中运行 Flink Job 时则会在本地启动一个 mini Flink 集群
  • Standalone:在 Flink 目录下执行 bin/start-cluster.sh 脚本则会启动一个 Standalone 模式的集群
  • YARN:YARN 是 Hadoop 集群的资源管理系统,它可以在群集上运行各种分布式应用程序,Flink 可与其他应用并行于 YARN 中,Flink on YARN 的架构如下:

undefined

  • Kubernetes:Kubernetes 是 Google 开源的容器集群管理系统,在 Docker 技术的基础上,为容器化的应用提供部署运行、资源调度、服务发现和动态伸缩等一系列完整功能,提高了大规模容器集群管理的便捷性,Flink 也支持部署在 Kubernetes 上,在 GitHub 看到有下面这种运行架构的。

undefined

通常上面四种居多,另外还支持 AWS、MapR、Aliyun OSS 等。

Flink 作业提交架构流程可见下图:

undefined

1、Program Code:我们编写的 Flink 应用程序代码

2、Job Client:Job Client 不是 Flink 程序执行的内部部分,但它是任务执行的起点。 Job Client 负责接受用户的程序代码,然后创建数据流,将数据流提交给 Job Manager 以便进一步执行。 执行完成后,Job Client 将结果返回给用户

3、Job Manager:主进程(也称为作业管理器)协调和管理程序的执行。 它的主要职责包括安排任务,管理 checkpoint ,故障恢复等。机器集群中至少要有一个 master,master 负责调度 task,协调 checkpoints 和容灾,高可用设置的话可以有多个 master,但要保证一个是 leader, 其他是 standby; Job Manager 包含 Actor system、Scheduler、Check pointing 三个重要的组件

4、Task Manager:从 Job Manager 处接收需要部署的 Task。Task Manager 是在 JVM 中的一个或多个线程中执行任务的工作节点。 任务执行的并行性由每个 Task Manager 上可用的任务槽(Slot 个数)决定。 每个任务代表分配给任务槽的一组资源。 例如,如果 Task Manager 有四个插槽,那么它将为每个插槽分配 25% 的内存。 可以在任务槽中运行一个或多个线程。 同一插槽中的线程共享相同的 JVM。 同一 JVM 中的任务共享 TCP 连接和心跳消息。Task Manager 的一个 Slot 代表一个可用线程,该线程具有固定的内存,注意 Slot 只对内存隔离,没有对 CPU 隔离。默认情况下,Flink 允许子任务共享 Slot,即使它们是不同 task 的 subtask,只要它们来自相同的 job。这种共享可以有更好的资源利用率。

undefined

Flink 提供了不同的抽象级别的 API 以开发流式或批处理应用。

  • 最底层提供了有状态流。它将通过 Process Function 嵌入到 DataStream API 中。它允许用户可以自由地处理来自一个或多个流数据的事件,并使用一致性、容错的状态。除此之外,用户可以注册事件时间和处理事件回调,从而使程序可以实现复杂的计算。
  • DataStream / DataSet API 是 Flink 提供的核心 API ,DataSet 处理有界的数据集,DataStream 处理有界或者无界的数据流。用户可以通过各种方法(map / flatmap / window / keyby / sum / max / min / avg / join 等)将数据进行转换或者计算。
  • Table API 是以表为中心的声明式 DSL,其中表可能会动态变化(在表达流数据时)。Table API 提供了例如 select、project、join、group-by、aggregate 等操作,使用起来却更加简洁(代码量更少)。 你可以在表与 DataStream/DataSet 之间无缝切换,也允许程序将 Table API 与 DataStream 以及 DataSet 混合使用。
  • Flink 提供的最高层级的抽象是 SQL 。这一层抽象在语法与表达能力上与 Table API 类似,但是是以 SQL查询表达式的形式表现程序。SQL 抽象与 Table API 交互密切,同时 SQL 查询可以直接在 Table API 定义的表上执行。

Flink 除了 DataStream 和 DataSet API,它还支持 Table/SQL API,Flink 也将通过 SQL API 来构建统一的大数据流批处理引擎,因为在公司中通常会有那种每天定时生成报表的需求(批处理的场景,每晚定时跑一遍昨天的数据生成一个结果报表),但是也是会有流处理的场景(比如采用 Flink 来做实时性要求很高的需求),于是慢慢的整个公司的技术选型就变得越来越多了,这样开发人员也就要面临着学习两套不一样的技术框架,运维人员也需要对两种不一样的框架进行环境搭建和作业部署,平时还要维护作业的稳定性。

当我们的系统变得越来越复杂了,作业越来越多了,这对于开发人员和运维来说简直就是噩梦,没准哪天凌晨晚上就被生产环境的告警电话给叫醒。所以 Flink 系统能通过 SQL API 来解决批流统一的痛点,这样不管是开发还是运维,他们只需要关注一个计算框架就行,从而减少企业的用人成本和后期开发运维成本。

undefined

undefined

一个完整的 Flink 应用程序结构就是如上两图所示:

1、Source:数据输入,Flink 在流处理和批处理上的 source 大概有 4 类:基于本地集合的 source、基于文件的 source、基于网络套接字的 source、自定义的 source。自定义的 source 常见的有 Apache kafka、Amazon Kinesis Streams、RabbitMQ、Twitter Streaming API、Apache NiFi 等,当然你也可以定义自己的 source。

2、Transformation:数据转换的各种操作,有 Map / FlatMap / Filter / KeyBy / Reduce / Fold / Aggregations / Window / WindowAll / Union / Window join / Split / Select / Project 等,操作很多,可以将数据转换计算成你想要的数据。

3、Sink:数据输出,Flink 将转换计算后的数据发送的地点 ,你可能需要存储下来,Flink 常见的 Sink 大概有如下几类:写入文件、打印出来、写入 socket 、自定义的 sink 。自定义的 sink 常见的有 Apache kafka、RabbitMQ、MySQL、ElasticSearch、Apache Cassandra、Hadoop FileSystem 等,同理你也可以定义自己的 sink。

undefined

通过源码可以发现不同版本的 Kafka、不同版本的 ElasticSearch、Cassandra、HBase、Hive、HDFS、RabbitMQ 都是支持的,除了流应用的 Connector 是支持的,另外还支持 SQL。

再就是要考虑计算的数据来源和数据最终存储,因为 Flink 在大数据领域的的定位就是实时计算,它不做存储(虽然 Flink 中也有 State 去存储状态数据,这里说的存储类似于 MySQL、ElasticSearch 等存储),所以在计算的时候其实你需要考虑的是数据源来自哪里,计算后的结果又存储到哪里去。庆幸的是 Flink 目前已经支持大部分常用的组件了,比如在 Flink 中已经支持了如下这些 Connector:

  • 不同版本的 Kafka
  • 不同版本的 ElasticSearch
  • Redis
  • MySQL
  • Cassandra
  • RabbitMQ
  • HBase
  • HDFS

这些 Connector 除了支持流作业外,目前还有还有支持 SQL 作业的,除了这些自带的 Connector 外,还可以通过 Flink 提供的接口做自定义 Source 和 Sink(在 3.8 节中)。

Flink 支持多种 Time,比如 Event time、Ingestion Time、Processing Time,后面的文章 Flink 中 Processing Time、Event Time、Ingestion Time 对比及其使用场景分析 中会很详细的讲解 Flink 中 Time 的概念。

undefined

Flink 支持多种 Window,比如 Time Window、Count Window、Session Window,还支持自定义 Window。后面的文章 如何使用 Flink Window 及 Window 基本概念与实现原理 中会很详细的讲解 Flink 中 Window 的概念。

undefined

Flink 的程序内在是并行和分布式的,数据流可以被分区成 stream partitions,operators 被划分为 operator subtasks; 这些 subtasks 在不同的机器或容器中分不同的线程独立运行; operator subtasks 的数量在具体的 operator 就是并行计算数,程序不同的 operator 阶段可能有不同的并行数;如下图所示,source operator 的并行数为 2,但最后的 sink operator 为 1:

undefined

Flink 是一款有状态的流处理框架,它提供了丰富的状态访问接口,按照数据的划分方式,可以分为 Keyed State 和 Operator State,在 Keyed State 中又提供了多种数据结构:

  • ValueState
  • MapState
  • ListState
  • ReducingState
  • AggregatingState

另外状态存储也支持多种方式:

  • MemoryStateBackend:存储在内存中
  • FsStateBackend:存储在文件中
  • RocksDBStateBackend:存储在 RocksDB 中

Flink 中支持使用 Checkpoint 来提高程序的可靠性,开启了 Checkpoint 之后,Flink 会按照一定的时间间隔对程序的运行状态进行备份,当发生故障时,Flink 会将所有任务的状态恢复至最后一次发生 Checkpoint 中的状态,并从那里开始重新开始执行。

另外 Flink 还支持根据 Savepoint 从已停止作业的运行状态进行恢复,这种方式需要通过命令进行触发。

//todo:深入内存到底要不要在第九章讲? Flink 在 JVM 中提供了自己的内存管理,使其独立于 Java 的默认垃圾收集器。 它通过使用散列,索引,缓存和排序有效地进行内存管理。我们在后面的文章 深入探索 Flink 内存管理机制 会深入讲解 Flink 里面的内存管理机制。

Flink 扩展库中含有机器学习、Gelly 图形处理、CEP 复杂事件处理、State Processing API 等,关于这块内容可以在第六章查看。

小结与反思

本节在开始介绍 Flink 之前先讲解了下数据集类型和数据运算模型,接着开始介绍 Flink 的各种特性,


三、大数据框架 Flink、Blink、Spark Streaming、Structured Streaming和 Storm 的区别。

Flink 是一个针对流数据和批数据分布式处理的引擎,在某些对实时性要求非常高的场景,基本上都是采用 Flink 来作为计算引擎,它不仅可以处理有界的批数据,还可以处理无界的流数据,在 Flink 的设计愿想就是将批处理当成是流处理的一种特例。

在 Flink 的母公司 Data Artisans 被阿里收购之后,阿里也在开始逐步将内部的 Blink 代码开源出来并合并在 Flink 主分支上。

undefined

而 Blink 一个很强大的特点就是它的 SQL API 很强大,社区也在 Flink 1.9 版本将 Blink 开源版本大部分代码合进了 Flink 主分支。

Blink 是早期阿里在 Flink 的基础上开始修改和完善后在内部创建的分支,然后 Blink 目前在阿里服务于阿里集团内部搜索、推荐、广告、菜鸟物流等大量核心实时业务。

undefined

Blink 在阿里内部错综复杂的业务场景中锻炼成长着,经历了内部这么多用户的反馈(各种性能、资源使用率、易用性等诸多方面的问题),Blink 都做了针对性的改进。在 Flink Forward China 峰会上,阿里巴巴集团副总裁周靖人宣布 Blink 在 2019 年 1 月正式开源,同时阿里也希望 Blink 开源后能进一步加深与 Flink 社区的联动,

Blink 开源地址:https://github.com/apache/flink/tree/blink

开源版本 Blink 的主要功能和优化点:

1、Runtime 层引入 Pluggable Shuffle Architecture,开发者可以根据不同的计算模型或者新硬件的需要实现不同的 shuffle 策略进行适配;为了性能优化,Blink 可以让算子更加灵活的 chain 在一起,避免了不必要的数据传输开销;在 BroadCast Shuffle 模式中,Blink 优化掉了大量的不必要的序列化和反序列化开销;Blink 提供了全新的 JM FailOver 机制,JM 发生错误之后,新的 JM 会重新接管整个 JOB 而不是重启 JOB,从而大大减少了 JM FailOver 对 JOB 的影响;Blink 支持运行在 Kubernetes 上。

2、SQL/Table API 架构上的重构和性能的优化是 Blink 开源版本的一个重大贡献。

3、Hive 的兼容性,可以直接用 Flink SQL 去查询 Hive 的数据,Blink 重构了 Flink catalog 的实现,并且增加了两种 catalog,一个是基于内存存储的 FlinkInMemoryCatalog,另外一个是能够桥接 Hive metaStore 的 HiveCatalog。

4、Zeppelin for Flink

5、Flink Web,更美观的 UI 界面,查看日志和监控 Job 都变得更加方便

对于开源那会看到一个对话让笔者感到很震撼:

1
2
3
Blink 开源后,两个开源项目之间的关系会是怎样的?未来 Flink 和 Blink 也会由不同的团队各自维护吗?

Blink 永远不会成为另外一个项目,如果后续进入 Apache 一定是成为 Flink 的一部分

undefined

在 Blink 开源那会,笔者就将源码自己编译了一份,然后自己在本地一直运行着,感兴趣的可以看看文章 阿里巴巴开源的 Blink 实时计算框架真香 ,你会发现 Blink 的 UI 还是比较美观和实用的。

如果你还对 Blink 有什么疑问,可以看看下面两篇文章:

阿里重磅开源 Blink:为什么我们等了这么久?

重磅!阿里巴巴 Blink 正式开源,重要优化点解读

1.3.3 Spark

Apache Spark 是一种包含流处理能力的下一代批处理框架。与 Hadoop 的 MapReduce 引擎基于各种相同原则开发而来的 Spark 主要侧重于通过完善的内存计算和处理优化机制加快批处理工作负载的运行速度。

Spark 可作为独立集群部署(需要相应存储层的配合),或可与 Hadoop 集成并取代 MapReduce 引擎。

Spark Streaming

undefined

Spark Streaming 是 Spark API 核心的扩展,可实现实时数据的快速扩展,高吞吐量,容错处理。数据可以从很多来源(如 Kafka、Flume、Kinesis 等)中提取,并且可以通过很多函数来处理这些数据,处理完后的数据可以直接存入数据库或者 Dashboard 等。

undefined

Spark Streaming 的内部实现原理是接收实时输入数据流并将数据分成批处理,然后由 Spark 引擎处理以批量生成最终结果流,也就是常说的 micro-batch 模式。

undefined

DStreams 是 Spark Streaming 提供的基本的抽象,它代表一个连续的数据流。。它要么是从源中获取的输入流,要么是输入流通过转换算子生成的处理后的数据流。在内部实现上,DStream 由连续的序列化 RDD 来表示,每个 RDD 含有一段时间间隔内的数据:

undefined

任何对 DStreams 的操作都转换成了对 DStreams 隐含的 RDD 的操作。例如 flatMap 操作应用于 lines 这个 DStreams 的每个 RDD,生成 words 这个 DStreams 的 RDD 过程如下图所示:

undefined

通过 Spark 引擎计算这些隐含 RDD 的转换算子。DStreams 操作隐藏了大部分的细节,并且为了更便捷,为开发者提供了更高层的 API。

Spark 支持的滑动窗口

undefined

它和 Flink 的滑动窗口类似,支持传入两个参数,一个代表窗口长度,一个代表滑动间隔。

Spark 支持更多的 API

因为 Spark 是使用 Scala 开发的居多,所以从官方文档就可以看得到对 Scala 的 API 支持的很好,而 Flink 源码实现主要以 Java 为主,因此也对 Java API 更友好,从两者目前支持的 API 友好程度,应该是 Spark 更好,它目前也支持 Python API,但是 Flink 新版本也在不断的支持 Python API。

Spark 支持更多的 Machine Learning Lib

你可以很轻松的使用 Spark MLlib 提供的机器学习算法,然后将这些这些机器学习算法模型应用在流数据中,目前 Flink Machine Learning 这块的内容还较少,不过阿里宣称会开源些 Flink Machine Learning 算法,保持和 Spark 目前已有的算法一致,我自己在 GitHub 上看到一个阿里开源的仓库,感兴趣的可以看看 flink-ai-extended

Spark Checkpoint

Spark 和 Flink 一样都支持 Checkpoint,但是 Flink 还支持 Savepoint,你可以在停止 Flink 作业的时候使用 Savepoint 将作业的状态保存下来,当作业重启的时候再从 Savepoint 中将停止作业那个时刻的状态恢复起来,保持作业的状态和之前一致。

Spark SQL

Spark 除了 DataFrames 和 Datasets 外,也还有 SQL API,这样你就可以通过 SQL 查询数据,另外 Spark SQL 还可以用于从 Hive 中读取数据。

从 Spark 官网也可以看到很多比较好的特性,这里就不一一介绍了,如果对 Spark 感兴趣的话也可以去官网了解一下具体的使用方法和实现原理。

Spark Streaming 优缺点

1、优点

  • Spark Streaming 内部的实现和调度方式高度依赖 Spark 的 DAG 调度器和 RDD,这就决定了 Spark Streaming 的设计初衷必须是粗粒度方式的,也就无法做到真正的实时处理
  • Spark Streaming 的粗粒度执行方式使其确保“处理且仅处理一次”的特性,同时也可以更方便地实现容错恢复机制。
  • 由于 Spark Streaming 的 DStream 本质是 RDD 在流式数据上的抽象,因此基于 RDD 的各种操作也有相应的基于 DStream 的版本,这样就大大降低了用户对于新框架的学习成本,在了解 Spark 的情况下用户将很容易使用 Spark Streaming。

2、缺点

  • Spark Streaming 的粗粒度处理方式也造成了不可避免的数据延迟。在细粒度处理方式下,理想情况下每一条记录都会被实时处理,而在 Spark Streaming 中,数据需要汇总到一定的量后再一次性处理,这就增加了数据处理的延迟,这种延迟是由框架的设计引入的,并不是由网络或其他情况造成的。
  • 使用的是 Processing Time 而不是 Event Time

Structured Streaming

Structured Streaming 是一种基于 Spark SQL 引擎的可扩展且容错的流处理引擎,它最关键的思想是将实时数据流视为一个不断增加的表,从而就可以像操作批的静态数据一样来操作流数据了。

undefined

会对输入的查询生成“结果表”,每个触发间隔(例如,每 1 秒)新行将附加到输入表,最终更新结果表,每当结果表更新时,我们希望能够将更改后的结果写入外部接收器去。

undefined

终于支持事件时间的窗口操作:

undefined

对比你会发现这个 Structured Streaming 怎么和 Flink 这么像,哈哈哈哈,不过这确实是未来的正确之路,两者的功能也会越来越相像的,期待它们出现更加令人兴奋的功能。

如果你对 Structured Streaming 感兴趣的话,可以去官网做更深一步的了解,顺带附上 Structured Streaming 的 Paper,同时也附上一位阿里小哥的 PPT —— From Spark Streaming to Structured Streaming

undefined

通过上面你应该可以了解到 Flink 对比 Spark Streaming 的微批处理来说是有一定的优势,并且 Flink 还有一些特别的优点,比如灵活的时间语义、多种时间窗口、结合水印处理延迟数据等,但是 Spark 也有自己的一些优势,功能在早期来说是很完善的,并且新版本的 Spark 还添加了 Structured Streaming,它和 Flink 的功能很相近,两个还是值得更深入的对比,期待后面官方的测试对比报告。

Storm

Storm 是一个开源的分布式实时计算系统,可以简单、可靠的处理大量的数据流。Storm 支持水平扩展,具有高容错性,保证每个消息都会得到处理,Strom 本身是无状态的,通过 ZooKeeper 管理分布式集群环境和集群状态。

Storm 核心组件

undefined

Nimbus:负责资源分配和任务调度,Nimbus 对任务的分配信息会存储在 Zookeeper 上面的目录下。

Supervisor:负责去 Zookeeper 上的指定目录接受 Nimbus 分配的任务,启动和停止属于自己管理的 Worker 进程。它是当前物理机器上的管理者 —— 通过配置文件设置当前 Supervisor 上启动多少个 Worker。

Worker:运行具体处理组件逻辑的进程,Worker 运行的任务类型只有两种,一种是 Spout 任务,一种是 Bolt 任务。

Task:Worker 中每一个 Spout/Bolt 的线程称为一个 Task. 在 Storm0.8 之后,Task 不再与物理线程对应,不同 Spout/Bolt 的 Task 可能会共享一个物理线程,该线程称为 Executor。

Worker、Task、Executor 三者之间的关系:

undefined

Storm 核心概念

  • Nimbus:Storm 集群主节点,负责资源分配和任务调度,任务的提交和停止都是在 Nimbus 上操作的,一个 Storm 集群只有一个 Nimbus 节点。
  • Supervisor:Storm 集群工作节点,接受 Nimbus 分配任务,管理所有 Worker。
  • Worker:工作进程,每个工作进程中都有多个 Task。
  • Executor:产生于 Worker 进程内部的线程,会执行同一个组件的一个或者多个 Task。
  • Task:任务,每个 Spout 和 Bolt 都是一个任务,每个任务都是一个线程。
  • Topology:计算拓扑,包含了应用程序的逻辑。
  • Stream:消息流,关键抽象,是没有边界的 Tuple 序列。
  • Spout:消息流的源头,Topology 的消息生产者。
  • Bolt:消息处理单元,可以过滤、聚合、查询数据库。
  • Tuple:数据单元,数据流中就是一个个 Tuple。
  • Stream grouping:消息分发策略,一共 6 种,控制 Tuple 的路由,定义 Tuple 在 Topology 中如何流动。
  • Reliability:可靠性,Storm 保证每个 Tuple 都会被处理。

Storm 数据处理流程图

Storm 处理数据的特点:数据源源不断,不断处理,数据都是 Tuple。

undefined

可以参考的文章有:

流计算框架 Flink 与 Storm 的性能对比

360 深度实践:Flink 与 Storm 协议级对比

两篇文章都从不同场景、不同数据压力下对比 Flink 和 Storm 两个实时计算框架的性能表现,最终结果都表明 Flink 比 Storm 的吞吐量和性能远超 Storm。

全部对比结果

undefined

如果对延迟要求不高的情况下,可以使用 Spark Streaming,它拥有丰富的高级 API,使用简单,并且 Spark 生态也比较成熟,吞吐量大,部署简单,社区活跃度较高,从 GitHub 的 star 数量也可以看得出来现在公司用 Spark 还是居多的,并且在新版本还引入了 Structured Streaming,这也会让 Spark 的体系更加完善。

如果对延迟性要求非常高的话,可以使用当下最火的流处理框架 Flink,采用原生的流处理系统,保证了低延迟性,在 API 和容错性方面做的也比较完善,使用和部署相对来说也是比较简单的,加上国内阿里贡献的 Blink,相信接下来 Flink 的功能将会更加完善,发展也会更加好,社区问题的响应速度也是非常快的,另外还有专门的钉钉大群和中文列表供大家提问,每周还会有专家进行直播讲解和答疑。

小结与反思

因在 1.2 节中已经对 Flink 的特性做了很详细的讲解,所以本篇主要介绍其他几种计算框架(Blink、Spark、Spark Streaming、Structured Streaming、Storm),并对比分析了这几种框架的特点与不同。你对这几种计算框架中的哪个最熟悉呢?了解过它们之间的差异吗?你有压测过它们的处理数据的性能吗?


通过前面几篇文章,相信你已经对 Flink 的基础概念等知识已经有一定了解,现在是不是迫切的想把 Flink 给用起来?先别急,我们先把电脑的准备环境给安装好,这样后面才能更愉快地玩耍。

废话不多说了,直奔主题。因为后面可能用到的有:Kafka、MySQL、ElasticSearch 等,另外像 Flink 编写程序还需要依赖 Java,还有就是我们项目是用 Maven 来管理依赖的,所以这篇文章我们先来安装下这个几个,准备好本地的环境,后面如果还要安装其他的组件我们到时在新文章中补充,如果你的操作系统已经中已经安装过 JDK、Maven、MySQL、IDEA 等,那么你可以跳过对应的内容,直接看你未安装过的。

这里我再说下我自己电脑的系统环境:macOS High Sierra 10.13.5,后面文章的演示环境不作特别说明的话就是都在这个系统环境中。

JDK 安装与配置

虽然现在 JDK 已经更新到 12 了,但是为了稳定我们还是安装 JDK 8,如果没有安装过的话,可以去官网下载页面下载对应自己操作系统的最新 JDK8 就行。

Mac 系统的是 jdk-8u211-macosx-x64.dmg 格式、Linux 系统的是 jdk-8u211-linux-x64.tar.gz 格式。

Mac 系统安装的话直接双击然后一直按照提示就行了,最后 JDK 的安装目录在 /Library/Java/JavaVirtualMachines/ ,然后在 /etc/hosts 中配置好环境变量(注意:替换你自己电脑本地的路径)。

1
2
3
export JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk1.8.0_152.jdk/Contents/Home
export CLASSPATH=$JAVA_HOME/lib/tools.jar:$JAVA_HOME/lib/dt.jar:
export PATH=$PATH:$JAVA_HOME/bin

Linux 系统的话就是在某个目录下直接解压就行了,然后在 /etc/profile 添加一下上面的环境变量(注意:替换你自己电脑的路径)。

然后执行 java -version 命令可以查看是否安装成功!

zhisheng@zhisheng ~ java -version
java version “1.8.0_152”
Java(TM) SE Runtime Environment (build 1.8.0_152-b16)
Java HotSpot(TM) 64-Bit Server VM (build 25.152-b16, mixed mode)

Maven 安装与配置

安装好 JDK 后我们就可以安装 Maven 了,我们在官网下载二进制包就行,然后在自己本地软件安装目录解压压缩包就行。

接下来你需要配置一下环境变量:

1
2
export M2_HOME=/Users/zhisheng/Documents/maven-3.5.2
export PATH=$PATH:$M2_HOME/bin

然后执行命令 mvn -v 可以验证是否安装成功,结果如下:

1
2
3
4
5
6
7
zhisheng@zhisheng ~ /Users  mvn -v
Apache Maven 3.5.2 (138edd61fd100ec658bfa2d307c43b76940a5d7d; 2017-10-18T15:58:13+08:00)
Maven home: /Users/zhisheng/Documents/maven-3.5.2
Java version: 1.8.0_152, vendor: Oracle Corporation
Java home: /Library/Java/JavaVirtualMachines/jdk1.8.0_152.jdk/Contents/Home/jre
Default locale: zh_CN, platform encoding: UTF-8
OS name: "mac os x", version: "10.13.5", arch: "x86_64", family: "mac"

IDE 安装与配置

安装完 JDK 和 Maven 后,就可以安装 IDE 了,大家可以选择你熟练的 IDE 就行,我后面演示的代码都是在 IDEA 中运行的,如果想为了后面不出其他的 问题的话,建议尽量和我的环境保持一致。

IDEA 官网下载地址:下载页面的地址

下载后可以双击后然后按照提示一步步安装,安装完成后需要在 IDEA 中配置 JDK 路径和 Maven 的路径,后面我们开发也都是靠 Maven 来管理项目的依赖。

MySQL 安装与配置

因为后面文章有用到 MySQL,所以这里也讲一下如何安装与配置,首先去官网下载 MySQL 5.7,下载页面的地址,根据你们到系统安装对应的版本,Mac 的话双击 dmg 安装包就可以按照提示一步步执行到安装成功。

启动 MySQL,如下图:

undefined

出现绿色就证明 MySQL 服务启动成功了。后面我们操作数据库不会通过本地命令行来,而是会通过图形化软件,比如:Navicat、Sequel pro,这些图形化软件可比命令行的效率高太多,读者可以自行下载安装一下。

Kafka 安装与配置

后面我们文章中会大量用到 Kafka,所以 Kakfa 一定要安装好。官网下载地址:下载页面的地址

同样,我自己下载的版本是 1.1.0 (保持和我公司的生产环境一致),如果你对 Kafka 还不太熟悉,可以参考我以前写的一篇入门文章:Kafka 安装及快速入门

在这篇文章里面教大家怎么安装 Kafka、启动 Zookeeper、启动 Kafka 服务、创建 Topic、使用 producer 创建消息、使用 consumer 消费消息、查看 Topic 的信息,另外还有提供集群配置的方案。

ElasticSearch 安装与配置

因为后面有文章介绍连接器 (connector) —— Elasticsearch 介绍和整和使用,并且最后面的案例文章也会把数据存储在 Elasticsearch 中的,所以这里就简单的讲解一下 Elasticsearch 的安装,在我以前的博客中写过一篇搭建 Elasticsearch 集群的:Elasticsearch 系列文章(二):全文搜索引擎 Elasticsearch 集群搭建入门教程

这里我在本地安装个单机的 Elasticsearch 就行了,首先在官网 下载页面 找到 Elasticsearch 产品,我下载的版本是 elasticsearch-6.3.2 版本,同样和我们公司的线上环境版本保持一致,因为 Flink Elasticsearch connector 有分好几个版本:2.x、5.x、6.x 版本,不同版本到时候写数据存入到 Elasticsearch 的 Job 代码也是有点区别的,如果你们公司的 Elasticsearch 版本比较低的话,到时候后面版本的学习代码还得找官网的资料对比学习一下。

另外就是写这篇文章的时候 Elasticsearch 7.x 就早已经发布了,Flink 我暂时还没看到支持 Elasticsearch 7 的连接器,自己也没测试过,所以暂不清楚如果用 6.x 版本的 connector 去连接 7.x 的 Elasticsearch 会不会出现问题?建议还是跟着我的安装版本来操作!

除了这样下载 Elasticsearch 的话,你如果电脑安装了 Homebrew,也可以通过 Homebrew 来安装 Elasticsearch,都还挺方便的,包括你还可以通过 Docker 的方式快速启动一个 Elasticsearch 来。

下载好了 Elasticsearch 的压缩包,在你的安装目录下解压就行了,然后进入 Elasticsearch 的安装目录执行下面命令就可以启动 Elasticsearch 了:

1
./bin/elasticsearch

执行命令后的结果:

undefined

从浏览器端打开地址:http://localhost:9200/ 即可验证是否安装成功:

undefined

如果出现了如上图这样就代表 Elasticsearch 环境已经安装好了。

小结与反思

本节讲解了下 JDK、Maven、IDE、MySQL、Kafka、ElasticSearch 的安装与配置,因为这些都是后面要用的,所以这里单独抽一篇文章来讲解环境准备的安装步骤,当然这里还并不涉及全,因为后面我们还可能会涉及到 HBase、HDFS 等知识,后面我们用到再看,我们本系列的文章更多的还是讲解 Flink,所以更多的环境准备还是得靠大家自己独立完成。

这里我说下笔者自己一般安装环境的选择:

  1. 组件尽量和公司的生产环境保持版本一致,不追求太新,够用就行,这样如果生产出现问题,本机还可以看是否可以复现出来
  2. 安装环境的时候先搜下类似的安装教程,提前知道要踩的坑,避免自己再次踩到

下面文章我们就正式进入 Flink 专题了!

五、Flink环境搭建

在 2.1 节中已经将 Flink 的准备环境已经讲完了,本篇文章将带大家正式开始接触 Flink,那么我们得先安装一下 Flink。Flink 是可以在多个平台(Windows、Linux、Mac)上安装的。在开始写本书的时候最新版本是 1.8 版本,但是写到一半后更新到 1.9 了(合并了大量 Blink 的新特性),所以笔者又全部更新版本到 1.9,书籍后面也都是基于最新的版本讲解与演示。

Flink 的官网地址是:https://flink.apache.org/

Mac & Linux 安装

你可以通过该地址 https://flink.apache.org/downloads.html 下载到最新版本的 Flink。

这里我们选择 Apache Flink 1.9.0 for Scala 2.11 版本,点击跳转到了一个镜像下载选择的地址,随便选择哪个就行,只是下载速度不一致而已。

undefined

下载完后,你就可以直接解压下载的 Flink 压缩包了。

接下来我们可以启动一下 Flink,我们进入到 Flink 的安装目录下执行命令 ./bin/start-cluster.sh 即可,产生的日志如下:

1
2
3
4
zhisheng@zhisheng /usr/local/flink-1.9.0  ./bin/start-cluster.sh
Starting cluster.
Starting standalonesession daemon on host zhisheng.
Starting taskexecutor daemon on host zhisheng.

如果你的电脑是 Mac 的话,那么你也可以通过 Homebrew 命令进行安装。先通过命令 brew search flink 查找一下包:

1
2
3
 zhisheng@zhisheng  ~  brew search flink
==> Formulae
apache-flink ✔ homebrew/linuxbrew-core/apache-flink

可以发现找得到 Flink 的安装包,但是这样安装的版本可能不是最新的,如果你要安装的话,则使用命令:

1
brew install apache-flink

那么它就会开始进行下载并安装好,安装后的目录应该是在 /usr/local/Cellar/apache-flink 下。

undefined

你可以通过下面命令检查安装的 Flink 到底是什么版本的:

1
flink --version

结果:

1
Version: 1.9.0, Commit ID: ff472b4

这种的话运行是得进入 /usr/local/Cellar/apache-flink/1.9.0/libexec/bin 目录下执行命令 ./start-cluster.sh 才可以启动 Flink 的。

启动后产生的日志:

1
2
3
Starting cluster.
Starting standalonesession daemon on host zhisheng.
Starting taskexecutor daemon on host zhisheng.

Windows 安装

如果你的电脑系统是 Windows 的话,那么你就直接双击 Flink 安装目录下面 bin 文件夹里面的 start-cluster.bat 就行,同样可以将 Flink 起动成功。

启动成功后的话,我们可以通过访问地址http://localhost:8081/ 查看 UI 长啥样了,如下图所示:

undefined

你在通过 jps 命令可以查看到运行的进程有:

1
2
3
4
5
zhisheng@zhisheng  /usr/local/flink-1.9.0  jps
73937 StandaloneSessionClusterEntrypoint
74391 Jps
520
74362 TaskManagerRunner

Flink 安装好后,我们也运行启动看了效果了,接下来我们来看下它的目录结构吧:

1
2
3
4
5
6
7
8
9
10
11
12
 ✘ zhisheng@zhisheng  /usr/local/flink-1.9.0  ll
total 1200
-rw-r--r--@ 1 zhisheng staff 11K 3 5 16:32 LICENSE
-rw-r--r--@ 1 zhisheng staff 582K 4 4 00:01 NOTICE
-rw-r--r--@ 1 zhisheng staff 1.3K 3 5 16:32 README.txt
drwxr-xr-x@ 26 zhisheng staff 832B 3 5 16:32 bin
drwxr-xr-x@ 14 zhisheng staff 448B 4 4 14:06 conf
drwxr-xr-x@ 6 zhisheng staff 192B 4 4 14:06 examples
drwxr-xr-x@ 5 zhisheng staff 160B 4 4 14:06 lib
drwxr-xr-x@ 47 zhisheng staff 1.5K 3 6 23:21 licenses
drwxr-xr-x@ 2 zhisheng staff 64B 3 5 19:50 log
drwxr-xr-x@ 22 zhisheng staff 704B 4 4 14:06 opt

上面目录:

  • bin 存放一些启动脚本
  • conf 存放配置文件
  • examples 存放一些案例的 Job Jar 包
  • lib Flink 依赖的 Jar 包
  • log 存放产生的日志文件
  • opt 存放的是一些可选择的 Jar 包,后面可能会用到

在 bin 目录里面有如下这些脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
zhisheng@zhisheng  /usr/local/flink-1.9.0  ll bin
total 256
-rwxr-xr-x@ 1 zhisheng staff 28K 3 5 16:32 config.sh
-rwxr-xr-x@ 1 zhisheng staff 2.2K 3 5 16:32 flink
-rwxr-xr-x@ 1 zhisheng staff 2.7K 3 5 16:32 flink-console.sh
-rwxr-xr-x@ 1 zhisheng staff 6.2K 3 5 16:32 flink-daemon.sh
-rwxr-xr-x@ 1 zhisheng staff 1.2K 3 5 16:32 flink.bat
-rwxr-xr-x@ 1 zhisheng staff 1.5K 3 5 16:32 historyserver.sh
-rwxr-xr-x@ 1 zhisheng staff 2.8K 3 5 16:32 jobmanager.sh
-rwxr-xr-x@ 1 zhisheng staff 1.8K 3 5 16:32 mesos-appmaster-job.sh
-rwxr-xr-x@ 1 zhisheng staff 1.8K 3 5 16:32 mesos-appmaster.sh
-rwxr-xr-x@ 1 zhisheng staff 1.8K 3 5 16:32 mesos-taskmanager.sh
-rwxr-xr-x@ 1 zhisheng staff 1.2K 3 5 16:32 pyflink-stream.sh
-rwxr-xr-x@ 1 zhisheng staff 1.1K 3 5 16:32 pyflink.bat
-rwxr-xr-x@ 1 zhisheng staff 1.1K 3 5 16:32 pyflink.sh
-rwxr-xr-x@ 1 zhisheng staff 3.4K 3 5 16:32 sql-client.sh
-rwxr-xr-x@ 1 zhisheng staff 2.5K 3 5 16:32 standalone-job.sh
-rwxr-xr-x@ 1 zhisheng staff 3.3K 3 5 16:32 start-cluster.bat
-rwxr-xr-x@ 1 zhisheng staff 1.8K 3 5 16:32 start-cluster.sh
-rwxr-xr-x@ 1 zhisheng staff 3.3K 3 5 16:32 start-scala-shell.sh
-rwxr-xr-x@ 1 zhisheng staff 1.8K 3 5 16:32 start-zookeeper-quorum.sh
-rwxr-xr-x@ 1 zhisheng staff 1.6K 3 5 16:32 stop-cluster.sh
-rwxr-xr-x@ 1 zhisheng staff 1.8K 3 5 16:32 stop-zookeeper-quorum.sh
-rwxr-xr-x@ 1 zhisheng staff 3.8K 3 5 16:32 taskmanager.sh
-rwxr-xr-x@ 1 zhisheng staff 1.6K 3 5 16:32 yarn-session.sh
-rwxr-xr-x@ 1 zhisheng staff 2.2K 3 5 16:32 zookeeper.sh

脚本包括了配置启动脚本、historyserver、Job Manager、Task Manager、启动集群和停止集群等脚本。

在 conf 目录下面有如下这些配置文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
zhisheng@zhisheng  /usr/local/flink-1.9.0  ll conf
total 112
-rw-r--r--@ 1 zhisheng staff 9.8K 4 4 00:01 flink-conf.yaml
-rw-r--r--@ 1 zhisheng staff 2.1K 3 5 16:32 log4j-cli.properties
-rw-r--r--@ 1 zhisheng staff 1.8K 3 5 16:32 log4j-console.properties
-rw-r--r--@ 1 zhisheng staff 1.7K 3 5 16:32 log4j-yarn-session.properties
-rw-r--r--@ 1 zhisheng staff 1.9K 3 5 16:32 log4j.properties
-rw-r--r--@ 1 zhisheng staff 2.2K 3 5 16:32 logback-console.xml
-rw-r--r--@ 1 zhisheng staff 1.5K 3 5 16:32 logback-yarn.xml
-rw-r--r--@ 1 zhisheng staff 2.3K 3 5 16:32 logback.xml
-rw-r--r--@ 1 zhisheng staff 15B 3 5 16:32 masters
-rw-r--r--@ 1 zhisheng staff 10B 3 5 16:32 slaves
-rw-r--r--@ 1 zhisheng staff 3.8K 3 5 16:32 sql-client-defaults.yaml
-rw-r--r--@ 1 zhisheng staff 1.4K 3 5 16:32 zoo.cfg

配置包含了 Flink 的自身配置、日志配置、masters、slaves、sql-client、zoo 等配置。

在 examples 目录里面可以看到有如下这些案例的目录:

1
2
3
4
5
6
zhisheng@zhisheng  /usr/local/flink-1.9.0  ll examples
total 0
drwxr-xr-x@ 10 zhisheng staff 320B 4 4 14:06 batch
drwxr-xr-x@ 3 zhisheng staff 96B 4 4 14:06 gelly
drwxr-xr-x@ 4 zhisheng staff 128B 4 4 14:06 python
drwxr-xr-x@ 11 zhisheng staff 352B 4 4 14:06 streaming

这个目录下面有批、gelly、python、流的 demo,后面我们可以直接用上面的案例做些简单的测试。

在 log 目录里面存着 Task Manager & Job manager 的日志:

1
2
3
4
5
6
zhisheng@zhisheng  /usr/local/flink-1.9.0  ll log
total 144
-rw-r--r-- 1 zhisheng staff 11K 4 25 20:10 flink-zhisheng-standalonesession-0-zhisheng.log
-rw-r--r-- 1 zhisheng staff 0B 4 25 20:10 flink-zhisheng-standalonesession-0-zhisheng.out
-rw-r--r-- 1 zhisheng staff 11K 4 25 20:10 flink-zhisheng-taskexecutor-0-zhisheng.log
-rw-r--r-- 1 zhisheng staff 0B 4 25 20:10 flink-zhisheng-taskexecutor-0-zhisheng.out

一般我们如果要深入了解一个知识点,最根本的方法就是看其源码实现,源码下面无秘密,所以我这里也讲一下如何将源码下载编译并运行,然后将代码工程导入到 IDEA 中去,方便自己查阅和 debug 代码。

Flink GitHub 仓库地址:https://github.com/apache/flink

执行下面命令将源码下载到本地:

1
git clone git@github.com:apache/flink.git

拉取的时候找个网络好点的地方,这样速度可能会更快点。

然后你可以切换到项目的不同分支,比如 release-1.9、blink(阿里巴巴开源贡献的) ,执行下面命令将代码切换到 release-1.9 分支:

1
git checkout release-1.9

或者你也想去看看 Blink 的代码实现,你也可以执行下面命令切换到 blink 分支来:

1
git checkout blink

编译源码的话,你需要执行如下命令:

1
mvn clean install -Dmaven.test.skip=true -Dmaven.javadoc.skip=true -Dcheckstyle.skip=true
  • -Dmaven.test.skip:跳过测试代码
  • -Dmaven.javadoc.skip:跳过 javadoc 检查
  • -Dcheckstyle.skip:跳过代码风格检查

maven 编译的时候跳过这些检查,这样可以减少很多时间,还可能会减少错误的发生。

注意:你的 maven 的 settings.xml 文件的 mirror 添加下面这个(这样才能下载到某些下载不了的依赖)。

1
2
3
4
5
6
7
8
9
10
11
12
13
<mirror>
<id>nexus-aliyun</id>
<mirrorOf>*,!jeecg,!jeecg-snapshots,!mapr-releases</mirrorOf>
<name>Nexus aliyun</name>
<url>http://maven.aliyun.com/nexus/content/groups/public</url>
</mirror>

<mirror>
<id>mapr-public</id>
<mirrorOf>mapr-releases</mirrorOf>
<name>mapr-releases</name>
<url>https://maven.aliyun.com/repository/mapr-public</url>
</mirror>

如果还遇到什么其他的问题的话,可以去看看我之前在我博客分享的一篇源码编译的文章(附视频):Flink 源码解析 —— 源码编译运行

看下图,因为我们已经下载好了源码,直接在 IDEA 里面 open 这个 maven 项目就行了:

undefined

导入后大概就是下面这样子:

undefined

很顺利,没多少报错,这里我已经把一些代码风格检查相关的 Maven 插件给注释掉了。

小结与反思

本节主要讲了 FLink 在不同系统下的安装和运行方法,然后讲了下怎么去下载源码和将源码导入到 IDE 中。不知道你在将源码导入到 IDE 中是否有遇到什么问题呢?

六、FlinkWordCount

在 2.2 中带大家讲解了下 Flink 的环境安装,这篇文章就开始我们的第一个 Flink 案例实战,也方便大家快速开始自己的第一个 Flink 应用。大数据里学习一门技术一般都是从 WordCount 开始入门的,那么我还是不打破常规了,所以这篇文章我也将带大家通过 WordCount 程序来初步了解 Flink。

Maven 创建项目

Flink 支持 Maven 直接构建模版项目,你在终端使用该命令:

1
2
3
4
mvn archetype:generate                               \
-DarchetypeGroupId=org.apache.flink \
-DarchetypeArtifactId=flink-quickstart-java \
-DarchetypeVersion=1.9.0

在执行的过程中它会提示你输入 groupId、artifactId、和 package 名,你按照要求输入就行,最后就可以成功创建一个项目。

undefined

进入到目录你就可以看到已经创建了项目,里面结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
 zhisheng@zhisheng  ~/IdeaProjects/github/Flink-WordCount  tree
.
├── pom.xml
└── src
└── main
├── java
│ └── com
│ └── zhisheng
│ ├── BatchJob.java
│ └── StreamingJob.java
└── resources
└── log4j.properties

6 directories, 4 files

该项目中包含了两个类 BatchJob 和 StreamingJob,另外还有一个 log4j.properties 配置文件,然后你就可以将该项目导入到 IDEA 了。

你可以在该目录下执行 mvn clean package 就可以编译该项目,编译成功后在 target 目录下会生成一个 Job 的 Jar 包,但是这个 Job 还不能执行,因为 StreamingJob 这个类中的 main 方法里面只是简单的创建了 StreamExecutionEnvironment 环境,然后就执行 execute 方法,这在 Flink 中是不算一个可执行的 Job 的,因此如果你提交到 Flink UI 上也是会报错的。

undefined

运行报错:

undefined

1
2
Server Response Message:
Internal server error.

我们查看 Flink Job Manager 的日志可以看到:

undefined

1
2
2019-04-26 17:27:33,706 ERROR org.apache.flink.runtime.webmonitor.handlers.JarRunHandler    - Unhandled exception.
org.apache.flink.client.program.ProgramInvocationException: The main method caused an error: No operators defined in streaming topology. Cannot execute.

因为 execute 方法之前我们是需要补充我们 Job 的一些算子操作的,所以报错还是很正常的,本文下面将会提供完整代码。

IDEA 创建项目

一般我们项目可能是由多个 Job 组成,并且代码也都是在同一个工程下面进行管理,上面那种适合单个 Job 执行,但如果多人合作的时候还是得在同一个工程下面进行项目的创建,每个 Flink Job 一个 module,下面我们将来讲解下如何利用 IDEA 创建 Flink 项目。

我们利用 IDEA 创建 Maven 项目,工程如下图这样,项目下面分很多模块,每个模块负责不同的业务

undefined

接下来我们需要在父工程的 pom.xml 中加入如下属性(含编码、Flink 版本、JDK 版本、Scala 版本、Maven 编译版本):

1
2
3
4
5
6
7
8
9
10
11
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!--Flink 版本-->
<flink.version>1.9.0</flink.version>
<!--JDK 版本-->
<java.version>1.8</java.version>
<!--Scala 2.11 版本-->
<scala.binary.version>2.11</scala.binary.version>
<maven.compiler.source>${java.version}</maven.compiler.source>
<maven.compiler.target>${java.version}</maven.compiler.target>
</properties>

然后加入依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<dependencies>
<!-- Apache Flink dependencies -->
<!-- These dependencies are provided, because they should not be packaged into the JAR file. -->
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-java</artifactId>
<version>${flink.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-streaming-java_${scala.binary.version}</artifactId>
<version>${flink.version}</version>
<scope>provided</scope>
</dependency>


<!-- Add logging framework, to produce console output when running in the IDE. -->
<!-- These dependencies are excluded from the application JAR by default. -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-log4j12</artifactId>
<version>1.7.7</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>log4j</groupId>
<artifactId>log4j</artifactId>
<version>1.2.17</version>
<scope>runtime</scope>
</dependency>
</dependencies>

上面依赖中 flink-java 和 flink-streaming-java 是我们 Flink 必备的核心依赖,为什么设置 scope 为 provided 呢(默认是 compile)?

是因为 Flink 其实在自己的安装目录中 lib 文件夹里的 lib/flink-dist_2.11-1.9.0.jar 已经包含了这些必备的 Jar 了,所以我们在给自己的 Flink Job 添加依赖的时候最后打成的 Jar 包可不希望又将这些重复的依赖打进去。有两个好处:

  • 减小了我们打的 Flink Job Jar 包容量大小
  • 不会因为打入不同版本的 Flink 核心依赖而导致类加载冲突等问题

但是问题又来了,我们需要在 IDEA 中调试运行我们的 Job,如果将 scope 设置为 provided 的话,是会报错的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Error: A JNI error has occurred, please check your installation and try again
Exception in thread "main" java.lang.NoClassDefFoundError: org/apache/flink/api/common/ExecutionConfig$GlobalJobParameters
at java.lang.Class.getDeclaredMethods0(Native Method)
at java.lang.Class.privateGetDeclaredMethods(Class.java:2701)
at java.lang.Class.privateGetMethodRecursive(Class.java:3048)
at java.lang.Class.getMethod0(Class.java:3018)
at java.lang.Class.getMethod(Class.java:1784)
at sun.launcher.LauncherHelper.validateMainClass(LauncherHelper.java:544)
at sun.launcher.LauncherHelper.checkAndLoadMain(LauncherHelper.java:526)
Caused by: java.lang.ClassNotFoundException: org.apache.flink.api.common.ExecutionConfig$GlobalJobParameters
at java.net.URLClassLoader.findClass(URLClassLoader.java:381)
at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:338)
at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
... 7 more

默认 scope 为 compile 的话,本地调试的话就不会出错了。

另外测试到底能够减小多少 Jar 包的大小呢?我这里先写了个 Job 测试。

当 scope 为 compile 时,编译后的 target 目录:

1
2
3
4
5
6
7
8
zhisheng@zhisheng  ~/Flink-WordCount/target   master ●✚  ll
total 94384
-rw-r--r-- 1 zhisheng staff 45M 4 26 21:23 Flink-WordCount-1.0-SNAPSHOT.jar
drwxr-xr-x 4 zhisheng staff 128B 4 26 21:23 classes
drwxr-xr-x 3 zhisheng staff 96B 4 26 21:23 generated-sources
drwxr-xr-x 3 zhisheng staff 96B 4 26 21:23 maven-archiver
drwxr-xr-x 3 zhisheng staff 96B 4 26 21:23 maven-status
-rw-r--r-- 1 zhisheng staff 7.2K 4 26 21:23 original-Flink-WordCount-1.0-SNAPSHOT.jar

当 scope 为 provided 时,编译后的 target 目录:

1
2
3
4
5
6
7
8
zhisheng@zhisheng ~/Flink-WordCount/target   master ●✚  ll
total 32
-rw-r--r-- 1 zhisheng staff 7.5K 4 26 21:27 Flink-WordCount-1.0-SNAPSHOT.jar
drwxr-xr-x 4 zhisheng staff 128B 4 26 21:27 classes
drwxr-xr-x 3 zhisheng staff 96B 4 26 21:27 generated-sources
drwxr-xr-x 3 zhisheng staff 96B 4 26 21:27 maven-archiver
drwxr-xr-x 3 zhisheng staff 96B 4 26 21:27 maven-status
-rw-r--r-- 1 zhisheng staff 7.2K 4 26 21:27 original-Flink-WordCount-1.0-SNAPSHOT.jar

可以发现:当 scope 为 provided 时 Jar 包才 7.5k,而为 compile 时 Jar 包就 45M 了,你要想想这才只是一个简单的 WordCount 程序呢,差别就这么大。当我们把 Flink Job 打成一个 fat Jar 时,上传到 UI 的时间就能够很明显的对比出来(Jar 包越小上传的时间越短),所以把 scope 设置为 provided 还是很有必要的。

有人就会想了,那这不是和上面有冲突了吗?假如我既想打出来的 Jar 包要小,又想能够在本地 IDEA 中进行运行和调试 Job ?这里我提供一种方法:在父工程中的 pom.xml 引入如下 profiles。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<profiles>
<profile>
<id>add-dependencies-for-IDEA</id>

<activation>
<property>
<name>idea.version</name>
</property>
</activation>

<dependencies>
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-java</artifactId>
<version>${flink.version}</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-streaming-java_${scala.binary.version}</artifactId>
<version>${flink.version}</version>
<scope>compile</scope>
</dependency>
</dependencies>
</profile>
</profiles>

当你在 IDEA 中运行 Job 的时候,它会给你引入 flink-java、flink-streaming-java,且 scope 设置为 compile,但是你是打成 Jar 包的时候它又不起作用。如果你加了这个 profile 还是报错的话,那么可能是 IDEA 中没有识别到,你可以在 IDEA 的中查看下面两个配置确定一下(配置其中一个即可以起作用)。

1、查看 Maven 中的该 profile 是否已经默认勾选上了,如果没有勾选上,则手动勾选一下才会起作用

undefined

2、Include dependencies with “Provided” scope 是否勾选,如果未勾选,则手动勾选后才起作用

undefined

流计算 WordCount 应用程序代码

回到正题,利用 IDEA 创建好 WordCount 应用后,我们开始编写代码。

Main 类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class Main {
public static void main(String[] args) throws Exception {
//创建流运行环境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.getConfig().setGlobalJobParameters(ParameterTool.fromArgs(args));
env.fromElements(WORDS)
.flatMap(new FlatMapFunction<String, Tuple2<String, Integer>>() {
@Override
public void flatMap(String value, Collector<Tuple2<String, Integer>> out) throws Exception {
String[] splits = value.toLowerCase().split("\\W+");

for (String split : splits) {
if (split.length() > 0) {
out.collect(new Tuple2<>(split, 1));
}
}
}
})
.keyBy(0)
.reduce(new ReduceFunction<Tuple2<String, Integer>>() {
@Override
public Tuple2<String, Integer> reduce(Tuple2<String, Integer> value1, Tuple2<String, Integer> value2) throws Exception {
return new Tuple2<>(value1.f0, value1.f1 + value1.f1);
}
})
.print();
//Streaming 程序必须加这个才能启动程序,否则不会有结果
env.execute("zhisheng —— word count streaming demo");
}

private static final String[] WORDS = new String[]{
"To be, or not to be,--that is the question:--",
"Whether 'tis nobler in the mind to suffer"
};
}

pom.xml 文件中引入 build 插件并且要替换成你自己项目里面的 mainClass:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
<build>
<plugins>
<!-- Java Compiler -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.1</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
</configuration>
</plugin>

<!-- 使用 maven-shade 插件创建一个包含所有必要的依赖项的 fat Jar -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.0.0</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<artifactSet>
<excludes>
<exclude>org.apache.flink:force-shading</exclude>
<exclude>com.google.code.findbugs:jsr305</exclude>
<exclude>org.slf4j:*</exclude>
<exclude>log4j:*</exclude>
</excludes>
</artifactSet>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<!--注意:这里一定要换成你自己的 Job main 方法的启动类-->
<mainClass>com.zhisheng.wordcount.Main</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>

注意:上面这个 build 插件要记得加,否则打出来的 jar 包是不完整的,提交运行会报 ClassNotFoundException,该问题是初学者很容易遇到的问题,很多人咨询过笔者这个问题。

WordCount 应用程序运行

本地 IDE 运行

编译好 WordCount 程序后,我们在 IDEA 中右键 run main 方法就可以把 Job 运行起来,结果如下图:

undefined

图中的就是将每个 word 和对应的个数一行一行打印出来,在本地 IDEA 中运行没有问题,我们接下来使用命令 mvn clean package 打包成一个 Jar (flink-learning-examples-1.0-SNAPSHOT.jar) 然后将其上传到 Flink UI 上运行一下看下效果。

UI 运行 Job

http://localhost:8081/#/submit 页面上传 flink-learning-examples-1.0-SNAPSHOT.jar 后,然后点击 Submit 后就可以运行了。

运行 Job 的 UI 如下:

undefined

Job 的结果在 Task Manager 的 Stdout 中:

undefined

WordCount 应用程序代码分析

我们已经将 WordCount 程序代码写好了并且也在 IDEA 中和 Flink UI 上运行了 Job,并且程序运行的结果都是正常的。

那么我们来分析一下这个 WordCount 程序代码:

1、创建好 StreamExecutionEnvironment(流程序的运行环境)

1
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

2、给流程序的运行环境设置全局的配置(从参数 args 获取)

1
env.getConfig().setGlobalJobParameters(ParameterTool.fromArgs(args));

3、构建数据源,WORDS 是个字符串数组

1
env.fromElements(WORDS)

4、将字符串进行分隔然后收集,组装后的数据格式是 (word、1),1 代表 word 出现的次数为 1

1
2
3
4
5
6
7
8
9
10
11
12
flatMap(new FlatMapFunction<String, Tuple2<String, Integer>>() {
@Override
public void flatMap(String value, Collector<Tuple2<String, Integer>> out) throws Exception {
String[] splits = value.toLowerCase().split("\\W+");

for (String split : splits) {
if (split.length() > 0) {
out.collect(new Tuple2<>(split, 1));
}
}
}
})

5、根据 word 关键字进行分组(0 代表对第一个字段分组,也就是对 word 进行分组)

1
keyBy(0)

6、对单个 word 进行计数操作

1
2
3
4
5
6
reduce(new ReduceFunction<Tuple2<String, Integer>>() {
@Override
public Tuple2<String, Integer> reduce(Tuple2<String, Integer> value1, Tuple2<String, Integer> value2) throws Exception {
return new Tuple2<>(value1.f0, value1.f1 + value2.f1);
}
})

7、打印所有的数据流,格式是 (word,count),count 代表 word 出现的次数

1
print()

8、开始执行 Job

1
env.execute("zhisheng —— word count streaming demo");

小结与反思

本节给大家介绍了 Maven 创建 Flink Job、IDEA 中创建 Flink 项目(详细描述了里面要注意的事情)、编写 WordCount 程序、IDEA 运行程序、在 Flink UI 运行程序、对 WordCount 程序每个步骤进行分析。

通过本小节,你接触了第一个 Flink 应用程序,也开启了 Flink 实战之旅。你有自己运行本节的代码去测试吗?动手测试的过程中有遇到什么问题吗?

本节涉及的代码地址:https://github.com/zhisheng17/flink-learning/tree/master/flink-learning-examples/src/main/java/com/zhisheng/examples/streaming/wordcount

在 2.3 中讲解了 Flink 最简单的 WordCount 程序的创建、运行结果查看和代码分析,这篇文章继续带大家来看一个入门上手的程序:Flink 处理 Socket 数据。

IDEA 创建项目

使用 IDEA 创建新的 module,结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
├── pom.xml
└── src
├── main
│ ├── java
│ │ └── com
│ │ └── zhisheng
│ │ └── socket
│ │ └── Main.java
│ └── resources
│ └── log4j.properties
└── test
└── java

项目创建好了后,我们下一步开始编写 Flink Socket Job 的代码。

Main 类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class Main {
public static void main(String[] args) throws Exception {
//参数检查
if (args.length != 2) {
System.err.println("USAGE:\nSocketTextStreamWordCount <hostname> <port>");
return;
}
String hostname = args[0];
Integer port = Integer.parseInt(args[1]);
final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
//获取数据
DataStreamSource<String> stream = env.socketTextStream(hostname, port);
//计数
SingleOutputStreamOperator<Tuple2<String, Integer>> sum = stream.flatMap(new LineSplitter())
.keyBy(0)
.sum(1);
sum.print();
env.execute("Java WordCount from SocketText");
}

public static final class LineSplitter implements FlatMapFunction<String, Tuple2<String, Integer>> {
@Override
public void flatMap(String s, Collector<Tuple2<String, Integer>> collector) {
String[] tokens = s.toLowerCase().split("\\W+");

for (String token: tokens) {
if (token.length() > 0) {
collector.collect(new Tuple2<String, Integer>(token, 1));
}
}
}
}
}

pom.xml 添加 build:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.1</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
</configuration>
</plugin>

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.0.0</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<artifactSet>
<excludes>
<exclude>org.apache.flink:force-shading</exclude>
<exclude>com.google.code.findbugs:jsr305</exclude>
<exclude>org.slf4j:*</exclude>
<exclude>log4j:*</exclude>
</excludes>
</artifactSet>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<!--注意:这里一定要换成你自己的 Job main 方法的启动类-->
<mainClass>com.zhisheng.socket.Main</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>

本地 IDE 运行

我们先在终端开启监听 9000 端口:

1
nc -l 9000

undefined

然后右键运行 Main 类的 main 方法 (注意:需要传入运行参数 127.0.0.1 9000):

undefined

运行结果如下图:

undefined

我在终端一个个输入下面的字符串:

1
2
3
4
5
6
7
hello
zhisheng
hello
hello
zhisheng
zhisheng
This is zhisheng‘s book

然后在 IDEA 的运行结果会一个个输出来:

1
2
3
4
5
6
7
8
9
10
11
2> (hello,1)
2> (zhisheng,1)
2> (hello,2)
2> (hello,3)
2> (zhisheng,2)
2> (zhisheng,3)
3> (s,1)
1> (this,1)
4> (is,1)
2> (zhisheng,4)
3> (book,1)

在本地 IDEA 中运行没有问题,我们接下来使用命令 mvn clean package 打包成一个 Jar (flink-learning-examples-1.0-SNAPSHOT.jar) 然后将其上传到 Flink UI 上运行一下看下效果。

UI 运行 Job

依旧和上面那样开启监听本地端口 9200,然后在 http://localhost:8081/#/submit 页面上传 flink-learning-examples-1.0-SNAPSHOT.jar 后,接着在 Main Class 填写运行的主函数,Program Arguments 填写参数 127.0.0.1 9000,最后点击 Submit 后就可以运行了。

undefined

UI 的运行详情如下图:

undefined

我在终端一个个输入下面的字符串:

1
2
3
4
5
6
7
8
9
10
11
12
zhisheng@zhisheng  ~  nc -l 9000
zhisheng
zhisheng's Book
This is zhisheng's Book
zhisheng
This is zhisheng's Book
This is zhisheng's Book
This is zhisheng's Book
This is zhisheng's Book
This is zhisheng's Book
This is zhisheng's Book
zhisheng

查看 Task Manager 的 Stdout 可以查看到输出:

undefined

1、参数检查,需要传入两个参数(hostname 和 port),符合条件就赋值给 hostname 和 port

1
2
3
4
5
6
7
if (args.length != 2) {
System.err.println("USAGE:\nSocketTextStreamWordCount <hostname> <port>");
return;
}

String hostname = args[0];
Integer port = Integer.parseInt(args[1]);

2、创建好 StreamExecutionEnvironment(流程序的运行环境)

1
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

3、构建数据源,获取 Socket 数据

1
DataStreamSource<String> stream = env.socketTextStream(hostname, port);

4、对 Socket 数据字符串分隔后收集在根据 word 分组后计数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
SingleOutputStreamOperator<Tuple2<String, Integer>> sum = stream.flatMap(new LineSplitter())
.keyBy(0)
.sum(1);

//将字符串进行分隔然后收集,组装后的数据格式是 (word、1),1 代表 word 出现的次数为 1
public static final class LineSplitter implements FlatMapFunction<String, Tuple2<String, Integer>> {
@Override
public void flatMap(String s, Collector<Tuple2<String, Integer>> collector) {
String[] tokens = s.toLowerCase().split("\\W+");

for (String token: tokens) {
if (token.length() > 0) {
collector.collect(new Tuple2<String, Integer>(token, 1));
}
}
}
}

5、打印所有的数据流,格式是 (word,count),count 代表 word 出现的次数

1
sum.print();

6、开始执行 Job

1
env.execute("Java WordCount from SocketText");

因为 Lambda 表达式看起来简洁,所以有时候也是希望在这些 Flink 作业中也可以使用上它,虽然 Flink 中是支持 Lambda,但是个人感觉不太友好。比如上面的应用程序如果将 LineSplitter 该类之间用 Lambda 表达式完成的话则要像下面这样写:

1
2
3
4
5
6
7
8
9
10
stream.flatMap((s, collector) -> {
for (String token : s.toLowerCase().split("\\W+")) {
if (token.length() > 0) {
collector.collect(new Tuple2<String, Integer>(token, 1));
}
}
})
.keyBy(0)
.sum(1)
.print();

但是这样写完后,运行作业报错如下:

1
2
3
4
5
6
7
8
9
10
11
12
Exception in thread "main" org.apache.flink.api.common.functions.InvalidTypesException: The return type of function 'main(LambdaMain.java:34)' could not be determined automatically, due to type erasure. You can give type information hints by using the returns(...) method on the result of the transformation call, or by letting your function implement the 'ResultTypeQueryable' interface.
at org.apache.flink.api.dag.Transformation.getOutputType(Transformation.java:417)
at org.apache.flink.streaming.api.datastream.DataStream.getType(DataStream.java:175)
at org.apache.flink.streaming.api.datastream.DataStream.keyBy(DataStream.java:318)
at com.zhisheng.examples.streaming.socket.LambdaMain.main(LambdaMain.java:41)
Caused by: org.apache.flink.api.common.functions.InvalidTypesException: The generic type parameters of 'Collector' are missing. In many cases lambda methods don't provide enough information for automatic type extraction when Java generics are involved. An easy workaround is to use an (anonymous) class instead that implements the 'org.apache.flink.api.common.functions.FlatMapFunction' interface. Otherwise the type has to be specified explicitly using type information.
at org.apache.flink.api.java.typeutils.TypeExtractionUtils.validateLambdaType(TypeExtractionUtils.java:350)
at org.apache.flink.api.java.typeutils.TypeExtractionUtils.extractTypeFromLambda(TypeExtractionUtils.java:176)
at org.apache.flink.api.java.typeutils.TypeExtractor.getUnaryOperatorReturnType(TypeExtractor.java:571)
at org.apache.flink.api.java.typeutils.TypeExtractor.getFlatMapReturnTypes(TypeExtractor.java:196)
at org.apache.flink.streaming.api.datastream.DataStream.flatMap(DataStream.java:611)
at com.zhisheng.examples.streaming.socket.LambdaMain.main(LambdaMain.java:34)

根据上面的报错信息其实可以知道要怎么解决了,该错误是因为 Flink 在用户自定义的函数中会使用泛型来创建 serializer,当使用匿名函数时,类型信息会被保留。但 Lambda 表达式并不是匿名函数,所以 javac 编译的时候并不会把泛型保存到 class 文件里。

解决方法:使用 Flink 提供的 returns 方法来指定 flatMap 的返回类型

1
2
//使用 TupleTypeInfo 来指定 Tuple 的参数类型
.returns((TypeInformation) TupleTypeInfo.getBasicTupleTypeInfo(String.class, Integer.class))

在 flatMap 后面加上上面这个 returns 就行了,但是如果算子多了的话,每个都去加一个 returns,其实会很痛苦的,所以通常使用匿名函数或者自定义函数居多。

小结与反思

本节讲了 Flink 的第二个应用程序 —— 读取 Socket 数据,希望通过两个简单的程序可以让你对 Flink 有个简单的认识,然后讲解了下 Flink 应用程序中使用 Lambda 表达式的问题。

本节涉及的代码地址:https://github.com/zhisheng17/flink-learning/tree/master/flink-learning-examples/src/main/java/com/zhisheng/examples/streaming/socket

八、Flink多种时间语义对比

Flink 在流应用程序中支持不同的 Time 概念,就比如有 Processing Time、Event Time 和 Ingestion Time。下面我们一起来看看这三个 Time。

Processing Time

Processing Time 是指事件被处理时机器的系统时间。

如果我们 Flink Job 设置的时间策略是 Processing Time 的话,那么后面所有基于时间的操作(如时间窗口)都将会使用当时机器的系统时间。每小时 Processing Time 窗口将包括在系统时钟指示整个小时之间到达特定操作的所有事件。

例如,如果应用程序在上午 9:15 开始运行,则第一个每小时 Processing Time 窗口将包括在上午 9:15 到上午 10:00 之间处理的事件,下一个窗口将包括在上午 10:00 到 11:00 之间处理的事件。

Processing Time 是最简单的 “Time” 概念,不需要流和机器之间的协调,它提供了最好的性能和最低的延迟。但是,在分布式和异步的环境下,Processing Time 不能提供确定性,因为它容易受到事件到达系统的速度(例如从消息队列)、事件在系统内操作流动的速度以及中断的影响。

Event Time

Event Time 是指事件发生的时间,一般就是数据本身携带的时间。这个时间通常是在事件到达 Flink 之前就确定的,并且可以从每个事件中获取到事件时间戳。在 Event Time 中,时间取决于数据,而跟其他没什么关系。Event Time 程序必须指定如何生成 Event Time 水印,这是表示 Event Time 进度的机制。

完美的说,无论事件什么时候到达或者其怎么排序,最后处理 Event Time 将产生完全一致和确定的结果。但是,除非事件按照已知顺序(事件产生的时间顺序)到达,否则处理 Event Time 时将会因为要等待一些无序事件而产生一些延迟。由于只能等待一段有限的时间,因此就难以保证处理 Event Time 将产生完全一致和确定的结果。

假设所有数据都已到达,Event Time 操作将按照预期运行,即使在处理无序事件、延迟事件、重新处理历史数据时也会产生正确且一致的结果。 例如,每小时事件时间窗口将包含带有落入该小时的事件时间戳的所有记录,不管它们到达的顺序如何(是否按照事件产生的时间)。

Ingestion Time

Ingestion Time 是事件进入 Flink 的时间。 在数据源操作处(进入 Flink source 时),每个事件将进入 Flink 时当时的时间作为时间戳,并且基于时间的操作(如时间窗口)会利用这个时间戳。

Ingestion Time 在概念上位于 Event Time 和 Processing Time 之间。 与 Processing Time 相比,成本可能会高一点,但结果更可预测。因为 Ingestion Time 使用稳定的时间戳(只在进入 Flink 的时候分配一次),所以对事件的不同窗口操作将使用相同的时间戳(第一次分配的时间戳),而在 Processing Time 中,每个窗口操作符可以将事件分配给不同的窗口(基于机器系统时间和到达延迟)。

与 Event Time 相比,Ingestion Time 程序无法处理任何无序事件或延迟数据,但程序中不必指定如何生成水印。

在 Flink 中,Ingestion Time 与 Event Time 非常相似,唯一区别就是 Ingestion Time 具有自动分配时间戳和自动生成水印功能。

三种 Time 对比结果

一张图概括上面说的三种 Time:

undefined

  • Processing Time:事件被处理时机器的系统时间
  • Event Time:事件自身的时间
  • Ingestion Time:事件进入 Flink 的时间

一张图形象描述上面说的三种 Time:

undefined

使用场景分析

通过上面两个图相信大家已经对 Flink 中的这三个 Time 有所了解了,那么我们实际生产环境中通常该如何选择哪种 Time 呢?

一般来说在生产环境中将 Event Time 与 Processing Time 对比的比较多,这两个也是我们常用的策略,Ingestion Time 一般用的较少。

用 Processing Time 的场景大多是用户不关心事件时间,它只需要关心这个时间窗口要有数据进来,只要有数据进来了,我就可以对进来窗口中的数据进行一系列的计算操作,然后再将计算后的数据发往下游。

而用 Event Time 的场景一般是业务需求需要时间这个字段(比如购物时是要先有下单事件、再有支付事件;借贷事件的风控是需要依赖时间来做判断的;机器异常检测触发的告警也是要具体的异常事件的时间展示出来;商品广告及时精准推荐给用户依赖的就是用户在浏览商品的时间段/频率/时长等信息),只能根据事件时间来处理数据,而且还要从事件中获取到事件的时间。

但是使用事件时间的话,就可能有这样的情况:数据源采集的数据往消息队列中发送时可能因为网络抖动、服务可用性、消息队列的分区数据堆积的影响而导致数据到达的不一定及时,可能会出现数据出现一定的乱序、延迟几分钟等,庆幸的是 Flink 支持通过 WaterMark 机制来处理这种延迟的数据。关于 WaterMark 的机制我会在后面的文章讲解。

如何设置 Time 策略?

在创建完流运行环境的时候,然后就可以通过 env.setStreamTimeCharacteristic 设置时间策略:

1
2
3
4
5
6
7
final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);

// 其他两种:
// env.setStreamTimeCharacteristic(TimeCharacteristic.IngestionTime);
// env.setStreamTimeCharacteristic(TimeCharacteristic.ProcessingTime);

小结与反思

本节介绍了 Flink 中的三种时间语义,相比较其他的流处理引擎来说支持的更多,你知道的流处理引擎支持哪些时间语义呢?

目前有许多数据分析的场景从批处理到流处理的演变, 虽然可以将批处理作为流处理的特殊情况来处理,但是分析无穷集的流数据通常需要思维方式的转变并且具有其自己的术语,例如,“windowing(窗口化)”、“at-least-once(至少一次)”、“exactly-once(只有一次)” 。

对于刚刚接触流处理的人来说,这种转变和新术语可能会非常混乱。 Apache Flink 是一个为生产环境而生的流处理器,具有易于使用的 API,可以用于定义高级流分析程序。Flink 的 API 在数据流上具有非常灵活的窗口定义,使其在其他开源流处理框架中脱颖而出。

在本节将讨论用于流处理的窗口的概念,介绍 Flink 的内置窗口,并解释它对自定义窗口语义的支持。

什么是 Window?

下面我们结合一个现实的例子来说明。

就拿交通传感器的示例:统计经过某红绿灯的汽车数量之和?

假设在一个红绿灯处,我们每隔 15 秒统计一次通过此红绿灯的汽车数量,如下图:

undefined

可以把汽车的经过看成一个流,无穷的流,不断有汽车经过此红绿灯,因此无法统计总共的汽车数量。但是,我们可以换一种思路,每隔 15 秒,我们都将与上一次的结果进行 sum 操作(滑动聚合),如下:

undefined

这个结果似乎还是无法回答我们的问题,根本原因在于流是无界的,我们不能限制流,但可以在有一个有界的范围内处理无界的流数据。因此,我们需要换一个问题的提法:每分钟经过某红绿灯的汽车数量之和?

这个问题,就相当于一个定义了一个 Window(窗口),Window 的界限是 1 分钟,且每分钟内的数据互不干扰,因此也可以称为翻滚(不重合)窗口,如下图:

undefined

第一分钟的数量为 18,第二分钟是 28,第三分钟是 24……这样,1 个小时内会有 60 个 Window。

再考虑一种情况,每 30 秒统计一次过去 1 分钟的汽车数量之和:

undefined

此时,Window 出现了重合。这样,1 个小时内会有 120 个 Window。

Window 有什么作用?

通常来讲,Window 就是用来对一个无限的流设置一个有限的集合,在有界的数据集上进行操作的一种机制。Window 又可以分为基于时间(Time-based)的 Window 以及基于数量(Count-based)的 window。

Flink 在 KeyedStream(DataStream 的继承类) 中提供了下面几种 Window:

  • 以时间驱动的 Time Window
  • 以事件数量驱动的 Count Window
  • 以会话间隔驱动的 Session Window

提供上面三种 Window 机制后,由于某些特殊的需要,DataStream API 也提供了定制化的 Window 操作,供用户自定义 Window。

下面将先围绕上面说的三种 Window 来进行分析并教大家如何使用,然后对其原理分析,最后在解析其源码实现。

Time Window 使用及源码分析

正如命名那样,Time Window 根据时间来聚合流数据。例如:一分钟的时间窗口就只会收集一分钟的元素,并在一分钟过后对窗口中的所有元素应用于下一个算子。

在 Flink 中使用 Time Window 非常简单,输入一个时间参数,这个时间参数可以利用 Time 这个类来控制,如果事前没指定 TimeCharacteristic 类型的话,则默认使用的是 ProcessingTime,如果对 Flink 中的 Time 还不了解的话,可以看前一篇文章 Flink 中 Processing Time、Event Time、Ingestion Time 对比及其使用场景分析 如下:

1
2
3
dataStream.keyBy(1)
.timeWindow(Time.minutes(1)) //time Window 每分钟统计一次数量和
.sum(1);

时间窗口的数据窗口聚合流程如下图所示:

undefined

在第一个窗口中(1 ~ 2 分钟)和为 7、第二个窗口中(2 ~ 3 分钟)和为 12、第三个窗口中(3 ~ 4 分钟)和为 7、第四个窗口中(4 ~ 5 分钟)和为 19。

该 timeWindow 方法在 KeyedStream 中对应的源码如下:

1
2
3
4
5
6
7
8
//时间窗口
public WindowedStream<T, KEY, TimeWindow> timeWindow(Time size) {
if (environment.getStreamTimeCharacteristic() == TimeCharacteristic.ProcessingTime) {
return window(TumblingProcessingTimeWindows.of(size));
} else {
return window(TumblingEventTimeWindows.of(size));
}
}

另外在 Time Window 中还支持滑动的时间窗口,比如定义了一个每 30s 滑动一次的 1 分钟时间窗口,它会每隔 30s 去统计过去一分钟窗口内的数据,同样使用也很简单,输入两个时间参数,如下:

1
2
3
dataStream.keyBy(1)
.timeWindow(Time.minutes(1), Time.seconds(30)) //sliding time Window 每隔 30s 统计过去一分钟的数量和
.sum(1);

滑动时间窗口的数据聚合流程如下图所示:

undefined

在该第一个时间窗口中(1 ~ 2 分钟)和为 7,第二个时间窗口中(1:30 ~ 2:30)和为 10,第三个时间窗口中(2 ~ 3 分钟)和为 12,第四个时间窗口中(2:30 ~ 3:30)和为 10,第五个时间窗口中(3 ~ 4 分钟)和为 7,第六个时间窗口中(3:30 ~ 4:30)和为 11,第七个时间窗口中(4 ~ 5 分钟)和为 19。

该 timeWindow 方法在 KeyedStream 中对应的源码如下:

1
2
3
4
5
6
7
8
//滑动时间窗口
public WindowedStream<T, KEY, TimeWindow> timeWindow(Time size, Time slide) {
if (environment.getStreamTimeCharacteristic() == TimeCharacteristic.ProcessingTime) {
return window(SlidingProcessingTimeWindows.of(size, slide));
} else {
return window(SlidingEventTimeWindows.of(size, slide));
}
}

Count Window 使用及源码分析

Apache Flink 还提供计数窗口功能,如果计数窗口的值设置的为 3 ,那么将会在窗口中收集 3 个事件,并在添加第 3 个元素时才会计算窗口中所有事件的值。

在 Flink 中使用 Count Window 非常简单,输入一个 long 类型的参数,这个参数代表窗口中事件的数量,使用如下:

1
2
3
dataStream.keyBy(1)
.countWindow(3) //统计每 3 个元素的数量之和
.sum(1);

计数窗口的数据窗口聚合流程如下图所示:

undefined

该 countWindow 方法在 KeyedStream 中对应的源码如下:

1
2
3
4
//计数窗口
public WindowedStream<T, KEY, GlobalWindow> countWindow(long size) {
return window(GlobalWindows.create()).trigger(PurgingTrigger.of(CountTrigger.of(size)));
}

另外在 Count Window 中还支持滑动的计数窗口,比如定义了一个每 3 个事件滑动一次的 4 个事件的计数窗口,它会每隔 3 个事件去统计过去 4 个事件计数窗口内的数据,使用也很简单,输入两个 long 类型的参数,如下:

1
2
3
dataStream.keyBy(1) 
.countWindow(4, 3) //每隔 3 个元素统计过去 4 个元素的数量之和
.sum(1);

滑动计数窗口的数据窗口聚合流程如下图所示:

undefined

该 countWindow 方法在 KeyedStream 中对应的源码如下:

1
2
3
4
//滑动计数窗口
public WindowedStream<T, KEY, GlobalWindow> countWindow(long size, long slide) {
return window(GlobalWindows.create()).evictor(CountEvictor.of(size)).trigger(CountTrigger.of(slide));
}

Session Window 使用及源码分析

Apache Flink 还提供了会话窗口,是什么意思呢?使用该窗口的时候你可以传入一个时间参数(表示某种数据维持的会话持续时长),如果超过这个时间,就代表着超出会话时长。

在 Flink 中使用 Session Window 非常简单,你该使用 Flink KeyedStream 中的 window 方法,然后使用 ProcessingTimeSessionWindows.withGap()(不一定就是只使用这个),在该方法里面你需要做的是传入一个时间参数,如下:

1
2
3
dataStream.keyBy(1)
.window(ProcessingTimeSessionWindows.withGap(Time.seconds(5)))//表示如果 5s 内没出现数据则认为超出会话时长,然后计算这个窗口的和
.sum(1);

会话窗口的数据窗口聚合流程如下图所示:

undefined

该 Window 方法在 KeyedStream 中对应的源码如下:

1
2
3
4
//提供自定义 Window
public <W extends Window> WindowedStream<T, KEY, W> window(WindowAssigner<? super T, W> assigner) {
return new WindowedStream<>(this, assigner);
}

如何自定义 Window?

当然除了上面几种自带的 Window 外,Apache Flink 还提供了用户可自定义的 Window,那么该如何操作呢?其实细心的同学可能已经发现了上面我写的每种 Window 的实现方式了,它们有 assigner、 evictor、trigger。如果你没发现的话,也不要紧,这里我们就来了解一下实现 Window 的机制,这样我们才能够更好的学会如何自定义 Window。

undefined

3.2.8 Window 源码定义

上面说了 Flink 中自带的 Window,主要利用了 KeyedStream 的 API 来实现,我们这里来看下 Window 的源码定义如下:

1
2
3
4
public abstract class Window {
//获取属于此窗口的最大时间戳
public abstract long maxTimestamp();
}

查看源码可以看见 Window 这个抽象类有如下实现类:

undefined

TimeWindow 源码定义如下:

1
2
3
4
5
6
public class TimeWindow extends Window {
//窗口开始时间
private final long start;
//窗口结束时间
private final long end;
}

GlobalWindow 源码定义如下:

1
2
3
4
5
6
7
8
9
10
public class GlobalWindow extends Window {

private static final GlobalWindow INSTANCE = new GlobalWindow();

private GlobalWindow() { }
//对外提供 get() 方法返回 GlobalWindow 实例,并且是个全局单例
public static GlobalWindow get() {
return INSTANCE;
}
}

Window 组件之 WindowAssigner 使用及源码分析

到达窗口操作符的元素被传递给 WindowAssigner。WindowAssigner 将元素分配给一个或多个窗口,可能会创建新的窗口。

窗口本身只是元素列表的标识符,它可能提供一些可选的元信息,例如 TimeWindow 中的开始和结束时间。注意,元素可以被添加到多个窗口,这也意味着一个元素可以同时在多个窗口存在。我们来看下 WindowAssigner 的代码的定义吧:

1
2
3
4
public abstract class WindowAssigner<T, W extends Window> implements Serializable {
//分配数据到窗口并返回窗口集合
public abstract Collection<W> assignWindows(T element, long timestamp, WindowAssignerContext context);
}

查看源码可以看见 WindowAssigner 这个抽象类有如下实现类:

undefined

这些 WindowAssigner 实现类的作用介绍:

TIM截图20191218145154.png

如果你细看了上面图中某个类的具体实现的话,你会发现一个规律,比如我拿 TumblingEventTimeWindows 的源码来分析,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class TumblingEventTimeWindows extends WindowAssigner<Object, TimeWindow> {
//定义属性
private final long size;
private final long offset;

//构造方法
protected TumblingEventTimeWindows(long size, long offset) {
if (Math.abs(offset) >= size) {
throw new IllegalArgumentException("TumblingEventTimeWindows parameters must satisfy abs(offset) < size");
}
this.size = size;
this.offset = offset;
}

//重写 WindowAssigner 抽象类中的抽象方法 assignWindows
@Override
public Collection<TimeWindow> assignWindows(Object element, long timestamp, WindowAssignerContext context) {
//实现该 TumblingEventTimeWindows 中的具体逻辑
}

//其他方法,对外提供静态方法,供其他类调用
}

从上面你就会发现套路

1、定义好实现类的属性

2、根据定义的属性添加构造方法

3、重写 WindowAssigner 中的 assignWindows 等方法

4、定义其他的方法供外部调用

Window 组件之 Trigger 使用及源码分析

Trigger 表示触发器,每个窗口都拥有一个 Trigger(触发器),该 Trigger 决定何时计算和清除窗口。当先前注册的计时器超时时,将为插入窗口的每个元素调用触发器。在每个事件上,触发器都可以决定触发,即清除(删除窗口并丢弃其内容),或者启动并清除窗口。一个窗口可以被求值多次,并且在被清除之前一直存在。注意,在清除窗口之前,窗口将一直消耗内存。

说了这么一大段,我们还是来看看 Trigger 的源码,定义如下:

1
2
3
4
5
6
7
8
public abstract class Trigger<T, W extends Window> implements Serializable {
//当有数据进入到 Window 运算符就会触发该方法
public abstract TriggerResult onElement(T element, long timestamp, W window, TriggerContext ctx) throws Exception;
//当使用触发器上下文设置的处理时间计时器触发时调用
public abstract TriggerResult onProcessingTime(long time, W window, TriggerContext ctx) throws Exception;
//当使用触发器上下文设置的事件时间计时器触发时调用该方法
public abstract TriggerResult onEventTime(long time, W window, TriggerContext ctx) throws Exception;
}

当有数据流入 Window 运算符时就会触发 onElement 方法、当处理时间和事件时间生效时会触发 onProcessingTime 和 onEventTime 方法。每个触发动作的返回结果用 TriggerResult 定义。继续来看下 TriggerResult 的源码定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public enum TriggerResult {

//不做任何操作
CONTINUE(false, false),

//处理并移除窗口中的数据
FIRE_AND_PURGE(true, true),

//处理窗口数据,窗口计算后不做清理
FIRE(true, false),

//清除窗口中的所有元素,并且在不计算窗口函数或不发出任何元素的情况下丢弃窗口
PURGE(false, true);
}

查看源码可以看见 Trigger 这个抽象类有如下实现类:

undefined

这些 Trigger 实现类的作用介绍:

undefined

如果你细看了上面图中某个类的具体实现的话,你会发现一个规律,拿 CountTrigger 的源码来分析,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class CountTrigger<W extends Window> extends Trigger<Object, W> {
//定义属性
private final long maxCount;

private final ReducingStateDescriptor<Long> stateDesc = new ReducingStateDescriptor<>("count", new Sum(), LongSerializer.INSTANCE);
//构造方法
private CountTrigger(long maxCount) {
this.maxCount = maxCount;
}

//重写抽象类 Trigger 中的抽象方法
@Override
public TriggerResult onElement(Object element, long timestamp, W window, TriggerContext ctx) throws Exception {
//实现 CountTrigger 中的具体逻辑
}

@Override
public TriggerResult onEventTime(long time, W window, TriggerContext ctx) {
return TriggerResult.CONTINUE;
}

@Override
public TriggerResult onProcessingTime(long time, W window, TriggerContext ctx) throws Exception {
return TriggerResult.CONTINUE;
}
}

套路

  1. 定义好实现类的属性
  2. 根据定义的属性添加构造方法
  3. 重写 Trigger 中的 onElement、onEventTime、onProcessingTime 等方法
  4. 定义其他的方法供外部调用

Window 组件之 Evictor 使用及源码分析

Evictor 表示驱逐者,它可以遍历窗口元素列表,并可以决定从列表的开头删除首先进入窗口的一些元素,然后其余的元素被赋给一个计算函数,如果没有定义 Evictor,触发器直接将所有窗口元素交给计算函数。

我们来看看 Evictor 的源码定义如下:

1
2
3
4
5
6
public interface Evictor<T, W extends Window> extends Serializable {
//在窗口函数之前调用该方法选择性地清除元素
void evictBefore(Iterable<TimestampedValue<T>> elements, int size, W window, EvictorContext evictorContext);
//在窗口函数之后调用该方法选择性地清除元素
void evictAfter(Iterable<TimestampedValue<T>> elements, int size, W window, EvictorContext evictorContext);
}

查看源码可以看见 Evictor 这个接口有如下实现类:

undefined

这些 Evictor 实现类的作用介绍:

undefined

如果你细看了上面三种中某个类的实现的话,你会发现一个规律,比如我就拿 CountEvictor 的源码来分析,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class CountEvictor<W extends Window> implements Evictor<Object, W> {
private static final long serialVersionUID = 1L;

//定义属性
private final long maxCount;
private final boolean doEvictAfter;

//构造方法
private CountEvictor(long count, boolean doEvictAfter) {
this.maxCount = count;
this.doEvictAfter = doEvictAfter;
}
//构造方法
private CountEvictor(long count) {
this.maxCount = count;
this.doEvictAfter = false;
}

//重写 Evictor 中的 evictBefore 方法
@Override
public void evictBefore(Iterable<TimestampedValue<Object>> elements, int size, W window, EvictorContext ctx) {
if (!doEvictAfter) {
//调用内部的关键实现方法 evict
evict(elements, size, ctx);
}
}

//重写 Evictor 中的 evictAfter 方法
@Override
public void evictAfter(Iterable<TimestampedValue<Object>> elements, int size, W window, EvictorContext ctx) {
if (doEvictAfter) {
//调用内部的关键实现方法 evict
evict(elements, size, ctx);
}
}

private void evict(Iterable<TimestampedValue<Object>> elements, int size, EvictorContext ctx) {
//内部的关键实现方法
}

//其他的方法
}

发现套路

  1. 定义好实现类的属性
  2. 根据定义的属性添加构造方法
  3. 重写 Evictor 中的 evictBefore 和 evictAfter 方法
  4. 定义关键的内部实现方法 evict,处理具体的逻辑
  5. 定义其他的方法供外部调用

上面我们详细讲解了 Window 中的组件 WindowAssigner、Trigger、Evictor,然后继续回到问题:如何自定义 Window?

上文讲解了 Flink 自带的 Window(Time Window、Count Window、Session Window),然后还分析了他们的源码实现,通过这几个源码,我们可以发现,它最后调用的都有一个方法,那就是 Window 方法,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
//提供自定义 Window
public <W extends Window> WindowedStream<T, KEY, W> window(WindowAssigner<? super T, W> assigner) {
return new WindowedStream<>(this, assigner);
}

//构造一个 WindowedStream 实例
public WindowedStream(KeyedStream<T, K> input,
WindowAssigner<? super T, W> windowAssigner) {
this.input = input;
this.windowAssigner = windowAssigner;
//获取一个默认的 Trigger
this.trigger = windowAssigner.getDefaultTrigger(input.getExecutionEnvironment());
}

可以看到这个 Window 方法传入的参数是一个 WindowAssigner 对象(你可以利用 Flink 现有的 WindowAssigner,也可以根据上面的方法来自定义自己的 WindowAssigner),然后再通过构造一个 WindowedStream 实例(在构造实例的会传入 WindowAssigner 和获取默认的 Trigger)来创建一个 Window。

另外你可以看到滑动计数窗口,在调用 window 方法之后,还调用了 WindowedStream 的 evictor 和 trigger 方法,trigger 方法会覆盖掉你之前调用 Window 方法中默认的 trigger,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//滑动计数窗口
public WindowedStream<T, KEY, GlobalWindow> countWindow(long size, long slide) {
return window(GlobalWindows.create()).evictor(CountEvictor.of(size)).trigger(CountTrigger.of(slide));
}

//trigger 方法
public WindowedStream<T, K, W> trigger(Trigger<? super T, ? super W> trigger) {
if (windowAssigner instanceof MergingWindowAssigner && !trigger.canMerge()) {
throw new UnsupportedOperationException("A merging window assigner cannot be used with a trigger that does not support merging.");
}

if (windowAssigner instanceof BaseAlignedWindowAssigner) {
throw new UnsupportedOperationException("Cannot use a " + windowAssigner.getClass().getSimpleName() + " with a custom trigger.");
}
//覆盖之前的 trigger
this.trigger = trigger;
return this;
}

从上面的各种窗口实现,你就会发现了:Evictor 是可选的,但是 WindowAssigner 和 Trigger 是必须会有的,这种创建 Window 的方法充分利用了 KeyedStream 和 WindowedStream 的 API,再加上现有的 WindowAssigner、Trigger、Evictor,你就可以创建 Window 了,另外你还可以自定义这三个窗口组件的实现类来满足你公司项目的需求。

小结与反思

本节从生活案例来分享关于 Window 方面的需求,进而开始介绍 Window 相关的知识,并把 Flink 中常使用的三种窗口都一一做了介绍,并告诉大家如何使用,还分析了其实现原理。最后还对 Window 的内部组件做了详细的分析,为自定义 Window 提供了方法。

不知道你看完本节后对 Window 还有什么疑问吗?你们是根据什么条件来选择使用哪种 Window 的?在使用的过程中有遇到什么问题吗?

十、数据转换必须熟悉的算子(Operator)

undefined

在 Flink 应用程序中,无论你的应用程序是批程序,还是流程序,都是上图这种模型,有数据源(source),有数据下游(sink),我们写的应用程序多是对数据源过来的数据做一系列操作,总结如下。

  1. Source: 数据源,Flink 在流处理和批处理上的 source 大概有 4 类:基于本地集合的 source、基于文件的 source、基于网络套接字的 source、自定义的 source。自定义的 source 常见的有 Apache kafka、Amazon Kinesis Streams、RabbitMQ、Twitter Streaming API、Apache NiFi 等,当然你也可以定义自己的 source。
  2. Transformation: 数据转换的各种操作,有 Map / FlatMap / Filter / KeyBy / Reduce / Fold / Aggregations / Window / WindowAll / Union / Window join / Split / Select / Project 等,操作很多,可以将数据转换计算成你想要的数据。
  3. Sink: 接收器,Sink 是指 Flink 将转换计算后的数据发送的地点 ,你可能需要存储下来。Flink 常见的 Sink 大概有如下几类:写入文件、打印出来、写入 Socket 、自定义的 Sink 。自定义的 sink 常见的有 Apache kafka、RabbitMQ、MySQL、ElasticSearch、Apache Cassandra、Hadoop FileSystem 等,同理你也可以定义自己的 Sink。

那么本文将给大家介绍的就是 Flink 中的批和流程序常用的算子(Operator)。

DataStream Operator

我们先来看看流程序中常用的算子。

Map

Map 算子的输入流是 DataStream,经过 Map 算子后返回的数据格式是 SingleOutputStreamOperator 类型,获取一个元素并生成一个元素,举个例子:

1
2
3
4
5
6
7
8
SingleOutputStreamOperator<Employee> map = employeeStream.map(new MapFunction<Employee, Employee>() {
@Override
public Employee map(Employee employee) throws Exception {
employee.salary = employee.salary + 5000;
return employee;
}
});
map.print();

新的一年给每个员工的工资加 5000。

FlatMap

FlatMap 算子的输入流是 DataStream,经过 FlatMap 算子后返回的数据格式是 SingleOutputStreamOperator 类型,获取一个元素并生成零个、一个或多个元素,举个例子:

1
2
3
4
5
6
7
8
9
SingleOutputStreamOperator<Employee> flatMap = employeeStream.flatMap(new FlatMapFunction<Employee, Employee>() {
@Override
public void flatMap(Employee employee, Collector<Employee> out) throws Exception {
if (employee.salary >= 40000) {
out.collect(employee);
}
}
});
flatMap.print();

将工资大于 40000 的找出来。

Filter

undefined

对每个元素都进行判断,返回为 true 的元素,如果为 false 则丢弃数据,上面找出工资大于 40000 的员工其实也可以用 Filter 来做:

1
2
3
4
5
6
7
8
9
10
SingleOutputStreamOperator<Employee> filter = employeeStream.filter(new FilterFunction<Employee>() {
@Override
public boolean filter(Employee employee) throws Exception {
if (employee.salary >= 40000) {
return true;
}
return false;
}
});
filter.print();

KeyBy

undefined

KeyBy 在逻辑上是基于 key 对流进行分区,相同的 Key 会被分到一个分区(这里分区指的就是下游算子多个并行节点的其中一个)。在内部,它使用 hash 函数对流进行分区。它返回 KeyedDataStream 数据流。举个例子:

1
2
3
4
5
6
7
KeyedStream<ProductEvent, Integer> keyBy = productStream.keyBy(new KeySelector<ProductEvent, Integer>() {
@Override
public Integer getKey(ProductEvent product) throws Exception {
return product.shopId;
}
});
keyBy.print();

根据商品的店铺 id 来进行分区。

Reduce

Reduce 返回单个的结果值,并且 reduce 操作每处理一个元素总是创建一个新值。常用的方法有 average、sum、min、max、count,使用 Reduce 方法都可实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
SingleOutputStreamOperator<Employee> reduce = employeeStream.keyBy(new KeySelector<Employee, Integer>() {
@Override
public Integer getKey(Employee employee) throws Exception {
return employee.shopId;
}
}).reduce(new ReduceFunction<Employee>() {
@Override
public Employee reduce(Employee employee1, Employee employee2) throws Exception {
employee1.salary = (employee1.salary + employee2.salary) / 2;
return employee1;
}
});
reduce.print();

上面先将数据流进行 keyby 操作,因为执行 Reduce 操作只能是 KeyedStream,然后将员工的工资做了一个求平均值的操作。

Aggregations

DataStream API 支持各种聚合,例如 min、max、sum 等。 这些函数可以应用于 KeyedStream 以获得 Aggregations 聚合。

1
2
3
4
5
6
7
8
9
10
KeyedStream.sum(0) 
KeyedStream.sum("key")
KeyedStream.min(0)
KeyedStream.min("key")
KeyedStream.max(0)
KeyedStream.max("key")
KeyedStream.minBy(0)
KeyedStream.minBy("key")
KeyedStream.maxBy(0)
KeyedStream.maxBy("key")

max 和 maxBy 之间的区别在于 max 返回流中的最大值,但 maxBy 返回具有最大值的键, min 和 minBy 同理。

Window

Window 函数允许按时间或其他条件对现有 KeyedStream 进行分组。 以下是以 10 秒的时间窗口聚合:

1
inputStream.keyBy(0).window(Time.seconds(10));

有时候因为业务需求场景要求:聚合一分钟、一小时的数据做统计报表使用。

WindowAll

WindowAll 将元素按照某种特性聚集在一起,该函数不支持并行操作,默认的并行度就是 1,所以如果使用这个算子的话需要注意一下性能问题,以下是使用例子:

1
inputStream.keyBy(0).windowAll(TumblingProcessingTimeWindows.of(Time.seconds(10)));

Union

undefined

Union 函数将两个或多个数据流结合在一起。 这样后面在使用的时候就只需使用一个数据流就行了。 如果我们将一个流与自身组合,那么组合后的数据流会有两份同样的数据。

1
inputStream.union(inputStream1, inputStream2, ...);

Window Join

我们可以通过一些 key 将同一个 window 的两个数据流 join 起来。

1
2
3
4
inputStream.join(inputStream1)
.where(0).equalTo(1)
.window(Time.seconds(5))
.apply (new JoinFunction () {...});

以上示例是在 5 秒的窗口中连接两个流,其中第一个流的第一个属性的连接条件等于另一个流的第二个属性。

Split

undefined

此功能根据条件将流拆分为两个或多个流。 当你获得混合流然后你可能希望单独处理每个数据流时,可以使用此方法。

1
2
3
4
5
6
7
8
9
10
11
12
SplitStream<Integer> split = inputStream.split(new OutputSelector<Integer>() {
@Override
public Iterable<String> select(Integer value) {
List<String> output = new ArrayList<String>();
if (value % 2 == 0) {
output.add("even");
} else {
output.add("odd");
}
return output;
}
});

上面就是将偶数数据流放在 even 中,将奇数数据流放在 odd 中。

Select

undefined

上面用 Split 算子将数据流拆分成两个数据流(奇数、偶数),接下来你可能想从拆分流中选择特定流,那么就得搭配使用 Select 算子(一般这两者都是搭配在一起使用的),

1
2
3
4
SplitStream<Integer> split;
DataStream<Integer> even = split.select("even");
DataStream<Integer> odd = split.select("odd");
DataStream<Integer> all = split.select("even","odd");

我们就介绍这么些常用的算子了,当然肯定也会有遗漏,具体还得查看官网 https://ci.apache.org/projects/flink/flink-docs-release-1.9/dev/stream/operators/ 的介绍。

DataSet Operator

上面介绍了 DataStream 的常用算子,其实上面也有一些算子也是同样适合于 DataSet 的,比如 Map、FlatMap、Filter 等(相同的我就不再重复了);也有一些算子是 DataSet API 独有的,比如 DataStream 中分区使用的是 KeyBy,但是 DataSet 中使用的是 GroupBy。

First-n

1
2
3
4
5
6
7
8
9
10
11
12
DataSet<Tuple2<String, Integer>> in = 
// 返回 DataSet 中前 5 的元素
DataSet<Tuple2<String, Integer>> out1 = in.first(5);

// 返回分组后每个组的前 2 元素
DataSet<Tuple2<String, Integer>> out2 = in.groupBy(0)
.first(2);

// 返回分组后每个组的前 3 元素(按照上升排序)
DataSet<Tuple2<String, Integer>> out3 = in.groupBy(0)
.sortGroup(1, Order.ASCENDING)
.first(3);

还有一些,感兴趣的可以查看官网 https://ci.apache.org/projects/flink/flink-docs-release-1.9/dev/batch/dataset_transformations.html。

流批统一的思路

一般公司里的业务场景需求肯定不止是只有批计算,也不只是有流计算的。一般这两种需求是都存在的。比如每天凌晨 00:00 去算昨天一天商品的售卖情况,然后出报表给运营或者老板去分析;另外的就是处理实时的数据。

但是这样就会有一个问题,需要让开发掌握两套 API。有些数据工程师的开发能力可能并不高,他们会更擅长写一些 SQL 去分析,所以要是掌握两套 API 的话,对他们来说成本可能会很大。要是 Flink 能够提供一种高级的 API,上层做好完全封装,让开发无感知底层到底运行的是 DataSet 还是 DataStream API,这样不管是开发还是数据工程师只需要学习一套高级的 API 就行。

Flink 社区包括阿里巴巴实时计算团队也在大力推广这块,那就是我们的 Flink Table API & SQL,在 Flink 1.9 版本,开源版本的 Blink 大部分代码已经合进去了,期待阿里实时计算团队为社区带来更多的贡献。

对于开发人员来说,流批统一的引擎(Table API & SQL)在执行之前会根据运行的环境翻译成 DataSet 或者 DataStream API。因为这两种 API 底层的实现有很大的区别,所以在统一流和批的过程中遇到了不少挑战。

  • 理论基础:动态表
  • 架构改进(统一的 Operator 框架、统一的查询处理)
  • 优化器的统一
  • 基础数据结构的统一
  • 物理实现的共享

关于 Table API & SQL,在进阶篇第五章中有讲解!

小结与反思

本节介绍了在开发 Flink 作业中数据转换常使用的算子(包含流作业和批作业),DataStream API 和 DataSet API 中部分算子名字是一致的,也有不同的地方,最后讲解了下 Flink 社区后面流批统一的思路。

你们公司使用 Flink 是流作业居多还是批作业居多?

十一、如何使用 DataStream API 来处理数据?

在 3.3 节中讲了数据转换常用的 Operators(算子),然后在 3.2 节中也讲了 Flink 中窗口的概念和原理,那么我们这篇文章再来细讲一下 Flink 中的各种 DataStream API。

我们先来看下源码里面的 DataStream 大概有哪些类呢?

undefined

可以发现其实还是有很多的类,只有熟练掌握了这些 API,我们才能在做数据转换和计算的时候足够灵活的运用开来(知道何时该选用哪种 DataStream?选用哪个 Function?)。那么我们先从 DataStream 开始吧!

DataStream 如何使用及分析

首先我们来看下 DataStream 这个类的定义吧:

1
2
A DataStream represents a stream of elements of the same type. A DataStreamcan be transformed into another DataStream by applying a transformation as
DataStream#map or DataStream#filter}

大概意思是:DataStream 表示相同类型的元素组成的数据流,一个数据流可以通过 map/filter 等算子转换成另一个数据流。

然后 DataStream 的类结构图如下:

undefined

它的继承类有 KeyedStream、SingleOutputStreamOperator 和 SplitStream。这几个类本文后面都会一一给大家讲清楚。下面我们来看看 DataStream 这个类中的属性和方法吧。

它的属性就只有两个:

1
2
3
protected final StreamExecutionEnvironment environment;

protected final StreamTransformation<T> transformation;

但是它的方法却有很多,并且我们平时写的 Flink Job 几乎离不开这些方法,这也注定了这个类的重要性,所以得好好看下这些方法该如何使用,以及是如何实现的。

union

通过合并相同数据类型的数据流,然后创建一个新的数据流,union 方法代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
public final DataStream<T> union(DataStream<T>... streams) {
List<StreamTransformation<T>> unionedTransforms = new ArrayList<>();
unionedTransforms.add(this.transformation);

for (DataStream<T> newStream : streams) {
if (!getType().equals(newStream.getType())) { //判断数据类型是否一致
throw new IllegalArgumentException("Cannot union streams of different types: " + getType() + " and " + newStream.getType());
}
unionedTransforms.add(newStream.getTransformation());
}
//构建新的数据流
return new DataStream<>(this.environment, new UnionTransformation<>(unionedTransforms));//通过使用 UnionTransformation 将多个 StreamTransformation 合并起来
}

那么我们该如何去使用 union 呢(不止连接一个数据流,也可以连接多个数据流)?

1
2
3
4
5
//数据流 1 和 2
final DataStream<Integer> stream1 = env.addSource(...);
final DataStream<Integer> stream2 = env.addSource(...);
//union
stream1.union(stream2)

split

该方法可以将两个数据流进行拆分,拆分后的数据流变成了 SplitStream(在下文会详细介绍这个类的内部实现),该 split 方法通过传入一个 OutputSelector 参数进行数据选择,方法内部实现就是构造一个 SplitStream 对象然后返回:

1
2
3
public SplitStream<T> split(OutputSelector<T> outputSelector) {
return new SplitStream<>(this, clean(outputSelector));
}

然后我们该如何使用这个方法呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
dataStream.split(new OutputSelector<Integer>() {
private static final long serialVersionUID = 8354166915727490130L;

@Override
public Iterable<String> select(Integer value) {
List<String> s = new ArrayList<String>();
if (value > 4) { //大于 4 的数据放到 > 这个 tag 里面去
s.add(">");
} else { //小于等于 4 的数据放到 < 这个 tag 里面去
s.add("<");
}
return s;
}
});

注意:该方法已经不推荐使用了!在 1.7 版本以后建议使用 Side Output 来实现分流操作。

connect

通过连接不同或相同数据类型的数据流,然后创建一个新的连接数据流,如果连接的数据流也是一个 DataStream 的话,那么连接后的数据流为 ConnectedStreams(会在下文介绍这个类的具体实现),它的具体实现如下:

1
2
3
public <R> ConnectedStreams<T, R> connect(DataStream<R> dataStream) {
return new ConnectedStreams<>(environment, this, dataStream);
}

如果连接的数据流是一个 BroadcastStream(广播数据流),那么连接后的数据流是一个 BroadcastConnectedStream(会在下文详细介绍该类的内部实现),它的具体实现如下:

1
2
3
4
5
public <R> BroadcastConnectedStream<T, R> connect(BroadcastStream<R> broadcastStream) {
return new BroadcastConnectedStream<>(
environment, this, Preconditions.checkNotNull(broadcastStream),
broadcastStream.getBroadcastStateDescriptor());
}

使用如下:

1
2
3
4
5
6
7
8
9
//1、连接 DataStream
DataStream<Tuple2<Long, Long>> src1 = env.fromElements(new Tuple2<>(0L, 0L));
DataStream<Tuple2<Long, Long>> src2 = env.fromElements(new Tuple2<>(0L, 0L));
ConnectedStreams<Tuple2<Long, Long>, Tuple2<Long, Long>> connected = src1.connect(src2);

//2、连接 BroadcastStream
DataStream<Tuple2<Long, Long>> src1 = env.fromElements(new Tuple2<>(0L, 0L));
final BroadcastStream<String> broadcast = srcTwo.broadcast(utterDescriptor);
BroadcastConnectedStream<Tuple2<Long, Long>, String> connect = src1.connect(broadcast);

keyBy

keyBy 方法是用来将数据进行分组的,通过该方法可以将具有相同 key 的数据划分在一起组成新的数据流,该方法有四种(它们的参数各不一样):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
//1、参数是 KeySelector 对象
public <K> KeyedStream<T, K> keyBy(KeySelector<T, K> key) {
...
return new KeyedStream<>(this, clean(key));//构造 KeyedStream 对象
}

//2、参数是 KeySelector 对象和 TypeInformation 对象
public <K> KeyedStream<T, K> keyBy(KeySelector<T, K> key, TypeInformation<K> keyType) {
...
return new KeyedStream<>(this, clean(key), keyType);//构造 KeyedStream 对象
}

//3、参数是 1 至多个字段(用 0、1、2... 表示)
public KeyedStream<T, Tuple> keyBy(int... fields) {
if (getType() instanceof BasicArrayTypeInfo || getType() instanceof PrimitiveArrayTypeInfo) {
return keyBy(KeySelectorUtil.getSelectorForArray(fields, getType()));
} else {
return keyBy(new Keys.ExpressionKeys<>(fields, getType()));//调用 private 的 keyBy 方法
}
}

//4、参数是 1 至多个字符串
public KeyedStream<T, Tuple> keyBy(String... fields) {
return keyBy(new Keys.ExpressionKeys<>(fields, getType()));//调用 private 的 keyBy 方法
}

//真正调用的方法
private KeyedStream<T, Tuple> keyBy(Keys<T> keys) {
return new KeyedStream<>(this, clean(KeySelectorUtil.getSelectorForKeys(keys,
getType(), getExecutionConfig())));
}

如何使用呢:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
DataStream<Event> dataStream = env.fromElements(
new Event(1, "zhisheng01", 1.0),
new Event(2, "zhisheng02", 2.0),
new Event(3, "zhisheng03", 2.1),
new Event(3, "zhisheng04", 3.0),
new SubEvent(4, "zhisheng05", 4.0, 1.0),
);

//第1种
dataStream.keyBy(new KeySelector<Event, Integer>() {

@Override
public Integer getKey(Event value) throws Exception {
return value.getId();
}
});

//第2种
dataStream.keyBy(new KeySelector<Event, Integer>() {

@Override
public Integer getKey(Event value) throws Exception {
return value.getId();
}
}, Types.STRING);

//第3种
dataStream.keyBy(0);

//第4种
dataStream.keyBy("zhisheng01", "zhisheng02");

partitionCustom

使用自定义分区器在指定的 key 字段上将 DataStream 分区,这个 partitionCustom 有 3 个不同参数的方法,分别要传入的参数有自定义分区 Partitioner 对象、位置、字符和 KeySelector。它们内部也都是调用了私有的 partitionCustom 方法。

broadcast

broadcast 是将数据流进行广播,然后让下游的每个并行 Task 中都可以获取到这份数据流,通常这些数据是一些配置,一般这些配置数据的数据量不能太大,否则资源消耗会比较大。这个 broadcast 方法也有两个,一个是无参数,它返回的数据是 DataStream;另一种的参数是 MapStateDescriptor,它返回的参数是 BroadcastStream(这个也会在下文详细介绍)。

使用方法:

1
2
3
4
5
6
7
8
9
10
//1、第一种
DataStream<Tuple2<Integer, String>> source = env.addSource(...).broadcast();

//2、第二种
final MapStateDescriptor<Long, String> utterDescriptor = new MapStateDescriptor<>(
"broadcast-state", BasicTypeInfo.LONG_TYPE_INFO, BasicTypeInfo.STRING_TYPE_INFO
);
final DataStream<String> srcTwo = env.fromCollection(expected.values());

final BroadcastStream<String> broadcast = srcTwo.broadcast(utterDescriptor);

map

map 方法需要传入的参数是一个 MapFunction,当然传入 RichMapFunction 也是可以的,它返回的是 SingleOutputStreamOperator(这个类在会在下文详细介绍),该 map 方法里面的实现如下:

1
2
3
4
5
6
7
public <R> SingleOutputStreamOperator<R> map(MapFunction<T, R> mapper) {

TypeInformation<R> outType = TypeExtractor.getMapReturnTypes(clean(mapper), getType(),
Utils.getCallLocationName(), true);
//调用 transform 方法
return transform("Map", outType, new StreamMap<>(clean(mapper)));
}

该方法平时使用的非常频繁,然后我们该如何使用这个方法呢:

1
2
3
4
5
6
7
8
dataStream.map(new MapFunction<Integer, String>() {
private static final long serialVersionUID = 1L;

@Override
public String map(Integer value) throws Exception {
return value.toString();
}
})

flatMap

flatMap 方法需要传入一个 FlatMapFunction 参数,当然传入 RichFlatMapFunction 也是可以的,如果你的 Flink Job 里面有连续的 filter 和 map 算子在一起,可以考虑使用 flatMap 一个算子来完成两个算子的工作,它返回的是 SingleOutputStreamOperator,该 flatMap 方法里面的实现如下:

1
2
3
4
5
6
7
8
public <R> SingleOutputStreamOperator<R> flatMap(FlatMapFunction<T, R> flatMapper) {

TypeInformation<R> outType = TypeExtractor.getFlatMapReturnTypes(clean(flatMapper),
getType(), Utils.getCallLocationName(), true);
//调用 transform 方法
return transform("Flat Map", outType, new StreamFlatMap<>(clean(flatMapper)));

}

该方法平时使用的非常频繁,使用方式如下:

1
2
3
4
5
6
dataStream.flatMap(new FlatMapFunction<Integer, Integer>() {
@Override
public void flatMap(Integer value, Collector<Integer> out) throws Exception {
out.collect(value);
}
})

process

在输入流上应用给定的 ProcessFunction,从而创建转换后的输出流,通过该方法返回的是 SingleOutputStreamOperator,具体代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public <R> SingleOutputStreamOperator<R> process(ProcessFunction<T, R> processFunction) {

TypeInformation<R> outType = TypeExtractor.getUnaryOperatorReturnType(
processFunction, ProcessFunction.class, 0, 1,
TypeExtractor.NO_INDEX, getType(), Utils.getCallLocationName(), true);
//调用下面的 process 方法
return process(processFunction, outType);
}

public <R> SingleOutputStreamOperator<R> process(
ProcessFunction<T, R> processFunction,
TypeInformation<R> outputType) {

ProcessOperator<T, R> operator = new ProcessOperator<>(clean(processFunction));
//调用 transform 方法
return transform("Process", outputType, operator);
}

使用方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
DataStreamSource<Long> data = env.generateSequence(0, 0);

//定义的 ProcessFunction
ProcessFunction<Long, Integer> processFunction = new ProcessFunction<Long, Integer>() {
private static final long serialVersionUID = 1L;

@Override
public void processElement(Long value, Context ctx,
Collector<Integer> out) throws Exception {
//具体逻辑
}

@Override
public void onTimer(long timestamp, OnTimerContext ctx,
Collector<Integer> out) throws Exception {
//具体逻辑
}
};

DataStream<Integer> processed = data.keyBy(new IdentityKeySelector<Long>()).process(processFunction);

filter

filter 用来过滤数据的,它需要传入一个 FilterFunction,然后返回的数据也是 SingleOutputStreamOperator,该方法的实现是:

1
2
3
public SingleOutputStreamOperator<T> filter(FilterFunction<T> filter) {
return transform("Filter", getType(), new StreamFilter<>(clean(filter)));
}

该方法平时使用非常多:

1
2
3
4
5
6
7
DataStream<String> filter1 = src
.filter(new FilterFunction<String>() {
@Override
public boolean filter(String value) throws Exception {
return "zhisheng".equals(value);
}
})

上面这些方法是平时写代码时用的非常多的方法,我们这里讲解了它们的实现原理和使用方式,当然还有其他方法,比如 assignTimestampsAndWatermarks、join、shuffle、forward、addSink、rebalance、iterate、coGroup、project、timeWindowAll、countWindowAll、windowAll、print 等,这里由于篇幅的问题就不一一展开来讲了。

SingleOutputStreamOperator 如何使用及分析

SingleOutputStreamOperator 这个类继承自 DataStream,所以 DataStream 中有的方法在这里也都有,那么这里就讲解下额外的方法的作用,如下。

  • name():该方法可以设置当前数据流的名称,如果设置了该值,则可以在 Flink UI 上看到该值;uid() 方法可以为算子设置一个指定的 ID,该 ID 有个作用就是如果想从 savepoint 恢复 Job 时是可以根据这个算子的 ID 来恢复到它之前的运行状态;
  • setParallelism() :该方法是为每个算子单独设置并行度的,这个设置优先于你通过 env 设置的全局并行度;
  • setMaxParallelism() :该为算子设置最大的并行度;
  • setResources():该方法有两个(参数不同),设置算子的资源,但是这两个方法对外还没开放(是私有的,暂时功能性还不全);
  • forceNonParallel():该方法强行将并行度和最大并行度都设置为 1;
  • setChainingStrategy():该方法对给定的算子设置 ChainingStrategy;
  • disableChaining():该这个方法设置后将禁止该算子与其他的算子 chain 在一起;
  • getSideOutput():该方法通过给定的 OutputTag 参数从 side output 中来筛选出对应的数据流。

KeyedStream 如何使用及分析

KeyedStream 是 DataStream 在根据 KeySelector 分区后的数据流,DataStream 中常用的方法在 KeyedStream 后也可以用(除了 shuffle、forward 和 keyBy 等分区方法),在该类中的属性分别是 KeySelector 和 TypeInformation。

DataStream 中的窗口方法只有 timeWindowAll、countWindowAll 和 windowAll 这三种全局窗口方法,但是在 KeyedStream 类中的种类就稍微多了些,新增了 timeWindow、countWindow 方法,并且是还支持滑动窗口。

除了窗口方法的新增外,还支持大量的聚合操作方法,比如 reduce、fold、sum、min、max、minBy、maxBy、aggregate 等方法(列举的这几个方法都支持多种参数的)。

最后就是它还有 asQueryableState() 方法,能够将 KeyedStream 发布为可查询的 ValueState 实例。

SplitStream 如何使用及分析

SplitStream 这个类比较简单,它代表着数据分流后的数据流了,它有一个 select 方法可以选择分流后的哪种数据流了,通常它是结合 split 使用的,对于单次分流来说还挺方便的。但是它是一个被废弃的类(Flink 1.7 后被废弃的,可以看下笔者之前写的一篇文章 Flink 从0到1学习—— Flink 不可以连续 Split(分流)? ),其实可以用 side output 来代替这种 split,后面文章中我们也会讲通过简单的案例来讲解一下该如何使用 side output 做数据分流操作。

因为这个类的源码比较少,我们可以看下这个类的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class SplitStream<OUT> extends DataStream<OUT> {

//构造方法
protected SplitStream(DataStream<OUT> dataStream, OutputSelector<OUT> outputSelector) {
super(dataStream.getExecutionEnvironment(), new SplitTransformation<OUT>(dataStream.getTransformation(), outputSelector));
}

//选择要输出哪种数据流
public DataStream<OUT> select(String... outputNames) {
return selectOutput(outputNames);
}

//上面那个 public 方法内部调用的就是这个方法,该方法是个 private 方法,对外隐藏了它是如何去找到特定的数据流。
private DataStream<OUT> selectOutput(String[] outputNames) {
for (String outName : outputNames) {
if (outName == null) {
throw new RuntimeException("Selected names must not be null");
}
}
//构造了一个 SelectTransformation 对象
SelectTransformation<OUT> selectTransform = new SelectTransformation<OUT>(this.getTransformation(), Lists.newArrayList(outputNames));
//构造了一个 DataStream 对象
return new DataStream<OUT>(this.getExecutionEnvironment(), selectTransform);
}
}

WindowedStream 如何使用及分析

虽然 WindowedStream 不是继承自 DataStream,并且我们在 3.1 节中也做了一定的讲解,但是当时没讲里面的 Function,所以在这里刚好一起做一个补充。

在 WindowedStream 类中定义的属性有 KeyedStream、WindowAssigner、Trigger、Evictor、allowedLateness 和 lateDataOutputTag。

  • KeyedStream:代表着数据流,数据分组后再开 Window
  • WindowAssigner:Window 的组件之一
  • Trigger:Window 的组件之一
  • Evictor:Window 的组件之一(可选)
  • allowedLateness:用户指定的允许迟到时间长
  • lateDataOutputTag:数据延迟到达的 Side output,如果延迟数据没有设置任何标记,则会被丢弃

在 3.1 节中我们讲了上面的三个窗口组件 WindowAssigner、Trigger、Evictor,并教大家该如何使用,那么在这篇文章我就不再重复,那么接下来就来分析下其他几个的使用方式和其实现原理。

先来看下 allowedLateness 这个它可以在窗口后指定允许迟到的时间长,使用如下:

1
2
3
dataStream.keyBy(0)
.timeWindow(Time.milliseconds(20))
.allowedLateness(Time.milliseconds(2))

lateDataOutputTag 这个它将延迟到达的数据发送到由给定 OutputTag 标识的 side output(侧输出),当水印经过窗口末尾(并加上了允许的延迟后),数据就被认为是延迟了。

对于 keyed windows 有五个不同参数的 reduce 方法可以使用,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//1、参数为 ReduceFunction
public SingleOutputStreamOperator<T> reduce(ReduceFunction<T> function) {
...
return reduce(function, new PassThroughWindowFunction<K, W, T>());
}

//2、参数为 ReduceFunction 和 WindowFunction
public <R> SingleOutputStreamOperator<R> reduce(ReduceFunction<T> reduceFunction, WindowFunction<T, R, K, W> function) {
...
return reduce(reduceFunction, function, resultType);
}

//3、参数为 ReduceFunction、WindowFunction 和 TypeInformation
public <R> SingleOutputStreamOperator<R> reduce(ReduceFunction<T> reduceFunction, WindowFunction<T, R, K, W> function, TypeInformation<R> resultType) {
...
return input.transform(opName, resultType, operator);
}

//4、参数为 ReduceFunction 和 ProcessWindowFunction
public <R> SingleOutputStreamOperator<R> reduce(ReduceFunction<T> reduceFunction, ProcessWindowFunction<T, R, K, W> function) {
...
return reduce(reduceFunction, function, resultType);
}

//5、参数为 ReduceFunction、ProcessWindowFunction 和 TypeInformation
public <R> SingleOutputStreamOperator<R> reduce(ReduceFunction<T> reduceFunction, ProcessWindowFunction<T, R, K, W> function, TypeInformation<R> resultType) {
...
return input.transform(opName, resultType, operator);
}

除了 reduce 方法,还有六个不同参数的 fold 方法、aggregate 方法;两个不同参数的 apply 方法、process 方法(其中你会发现这两个 apply 方法和 process 方法内部其实都隐式的调用了一个私有的 apply 方法);其实除了前面说的两个不同参数的 apply 方法外,还有四个其他的 apply 方法,这四个方法也是参数不同,但是其实最终的是利用了 transform 方法;还有的就是一些预定义的聚合方法比如 sum、min、minBy、max、maxBy,它们的方法参数的个数不一致,这些预聚合的方法内部调用的其实都是私有的 aggregate 方法,该方法允许你传入一个 AggregationFunction 参数。我们来看一个具体的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//max
public SingleOutputStreamOperator<T> max(String field) {
//内部调用私有的的 aggregate 方法
return aggregate(new ComparableAggregator<>(field, input.getType(), AggregationFunction.AggregationType.MAX, false, input.getExecutionConfig()));
}

//私有的 aggregate 方法
private SingleOutputStreamOperator<T> aggregate(AggregationFunction<T> aggregator) {
//继续调用的是 reduce 方法
return reduce(aggregator);
}

//该 reduce 方法内部其实又是调用了其他多个参数的 reduce 方法
public SingleOutputStreamOperator<T> reduce(ReduceFunction<T> function) {
...
function = input.getExecutionEnvironment().clean(function);
return reduce(function, new PassThroughWindowFunction<K, W, T>());
}

从上面的方法调用过程,你会发现代码封装的很深,得需要你自己好好跟一下源码才可以了解更深些。

上面讲了这么多方法,你会发现 reduce 方法其实是用的蛮多的之一,那么就来看看该如何使用:

1
2
3
4
5
6
7
8
9
dataStream.keyBy(0)
.window(TumblingEventTimeWindows.of(Time.seconds(5)))
.reduce(new ReduceFunction<Tuple2<String, Integer>>() {
@Override
public Tuple2<String, Integer> reduce(Tuple2<String, Integer> value1, Tuple2<String, Integer> value2) {
return value1;
}
})
.print();

AllWindowedStream 如何使用及分析

前面讲完了 WindowedStream,再来看看这个 AllWindowedStream 你会发现它的实现其实无太大区别,该类中的属性和方法都和前面 WindowedStream 是一样的,然后我们就不再做过多的介绍,直接来看看该如何使用呢?

AllWindowedStream 这种场景下是不需要让数据流做 keyBy 分组操作,直接就进行 windowAll 操作,然后在 windowAll 方法中传入 WindowAssigner 参数对象即可,然后返回的数据结果就是 AllWindowedStream 了,下面使用方式继续执行了 AllWindowedStream 中的 reduce 方法来返回数据:

1
2
3
4
5
6
7
8
9
10
dataStream.windowAll(SlidingEventTimeWindows.of(Time.of(1, TimeUnit.SECONDS), Time.of(100, TimeUnit.MILLISECONDS)))
.reduce(new RichReduceFunction<Tuple2<String, Integer>>() {
private static final long serialVersionUID = -6448847205314995812L;

@Override
public Tuple2<String, Integer> reduce(Tuple2<String, Integer> value1,
Tuple2<String, Integer> value2) throws Exception {
return value1;
}
});

ConnectedStreams 如何使用及分析

ConnectedStreams 这个类定义是表示(可能)两个不同数据类型的数据连接流,该场景如果对一个数据流进行操作会直接影响另一个数据流,因此可以通过流连接来共享状态。比较常见的一个例子就是一个数据流(随时间变化的规则数据流)通过连接其他的数据流,这样另一个数据流就可以利用这些连接的规则数据流。

ConnectedStreams 在概念上可以认为和 Union 数据流是一样的。

在 ConnectedStreams 类中有三个属性:environment、inputStream1 和 inputStream2,该类中的方法如下:

undefined

在 ConnectedStreams 中可以通过 getFirstInput 获取连接的第一个流、通过 getSecondInput 获取连接的第二个流,同时它还含有六个 keyBy 方法来将连接后的数据流进行分组,这六个 keyBy 方法的参数各有不同。另外它还含有 map、flatMap、process 方法来处理数据(其中 map 和 flatMap 方法的参数分别使用的是 CoMapFunction 和 CoFlatMapFunction),其实如果你细看其方法里面的实现就会发现都是调用的 transform 方法。

上面讲完了 ConnectedStreams 类的基础定义,接下来我们来看下该类如何使用呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
DataStream<Tuple2<Long, Long>> src1 = env.fromElements(new Tuple2<>(0L, 0L));    //流 1
DataStream<Tuple2<Long, Long>> src2 = env.fromElements(new Tuple2<>(0L, 0L)); //流 2
ConnectedStreams<Tuple2<Long, Long>, Tuple2<Long, Long>> connected = src1.connect(src2); //连接流 1 和流 2

//使用连接流的六种 keyBy 方法
ConnectedStreams<Tuple2<Long, Long>, Tuple2<Long, Long>> connectedGroup1 = connected.keyBy(0, 0);
ConnectedStreams<Tuple2<Long, Long>, Tuple2<Long, Long>> connectedGroup2 = connected.keyBy(new int[]{0}, new int[]{0});
ConnectedStreams<Tuple2<Long, Long>, Tuple2<Long, Long>> connectedGroup3 = connected.keyBy("f0", "f0");
ConnectedStreams<Tuple2<Long, Long>, Tuple2<Long, Long>> connectedGroup4 = connected.keyBy(new String[]{"f0"}, new String[]{"f0"});
ConnectedStreams<Tuple2<Long, Long>, Tuple2<Long, Long>> connectedGroup5 = connected.keyBy(new FirstSelector(), new FirstSelector());
ConnectedStreams<Tuple2<Long, Long>, Tuple2<Long, Long>> connectedGroup5 = connected.keyBy(new FirstSelector(), new FirstSelector(), Types.STRING);

//使用连接流的 map 方法
connected.map(new CoMapFunction<Tuple2<Long, Long>, Tuple2<Long, Long>, Object>() {
private static final long serialVersionUID = 1L;

@Override
public Object map1(Tuple2<Long, Long> value) {
return null;
}

@Override
public Object map2(Tuple2<Long, Long> value) {
return null;
}
});

//使用连接流的 flatMap 方法
connected.flatMap(new CoFlatMapFunction<Tuple2<Long, Long>, Tuple2<Long, Long>, Tuple2<Long, Long>>() {

@Override
public void flatMap1(Tuple2<Long, Long> value, Collector<Tuple2<Long, Long>> out) throws Exception {}

@Override
public void flatMap2(Tuple2<Long, Long> value, Collector<Tuple2<Long, Long>> out) throws Exception {}

}).name("testCoFlatMap")

//使用连接流的 process 方法
connected.process(new CoProcessFunction<Tuple2<Long, Long>, Tuple2<Long, Long>, Tuple2<Long, Long>>() {
@Override
public void processElement1(Tuple2<Long, Long> value, Context ctx, Collector<Tuple2<Long, Long>> out) throws Exception {
if (value.f0 < 3) {
out.collect(value);
ctx.output(sideOutputTag, "sideout1-" + String.valueOf(value));
}
}

@Override
public void processElement2(Tuple2<Long, Long> value, Context ctx, Collector<Tuple2<Long, Long>> out) throws Exception {
if (value.f0 >= 3) {
out.collect(value);
ctx.output(sideOutputTag, "sideout2-" + String.valueOf(value));
}
}
});

BroadcastStream 如何使用及分析

BroadcastStream 这个类定义是表示 broadcast state(广播状态)组成的数据流。通常这个 BroadcastStream 数据流是通过调用 DataStream 中的 broadcast 方法才返回的,注意 BroadcastStream 后面不能使用算子去操作这些流,唯一可以做的就是使用 KeyedStream/DataStream 的 connect 方法去连接 BroadcastStream,连接之后的话就会返回一个 BroadcastConnectedStream 数据流。

在 BroadcastStream 中我们该如何使用呢?通常是在 DataStream 中使用 broadcast 方法,该方法需要传入一个 MapStateDescriptor 对象,可以看下该方法的实现如下:

1
2
3
4
5
public BroadcastStream<T> broadcast(final MapStateDescriptor<?, ?>... broadcastStateDescriptors) {
Preconditions.checkNotNull(broadcastStateDescriptors); //检查是否为空
final DataStream<T> broadcastStream = setConnectionType(new BroadcastPartitioner<>());
return new BroadcastStream<>(environment, broadcastStream, broadcastStateDescriptors); //构建 BroadcastStream 对象,传入 env 环境、broadcastStream 和 broadcastStateDescriptors
}

上面方法传入的参数 broadcastStateDescriptors,我们可以像下面这样去定义一个 MapStateDescriptor 对象:

1
2
3
final MapStateDescriptor<Long, String> utterDescriptor = new MapStateDescriptor<>(
"broadcast-state", BasicTypeInfo.LONG_TYPE_INFO, BasicTypeInfo.STRING_TYPE_INFO
);

BroadcastConnectedStream 如何使用及分析

BroadcastConnectedStream 这个类定义是表示 keyed 或者 non-keyed 数据流和 BroadcastStream 数据流进行连接后组成的数据流。比如在 DataStream 中执行 connect 方法就可以连接两个数据流了,那么在 DataStream 中 connect 方法实现如下:

1
2
3
4
5
6
7
public <R> BroadcastConnectedStream<T, R> connect(BroadcastStream<R> broadcastStream) {
return new BroadcastConnectedStream<>( //构造 BroadcastConnectedStream 对象
environment,
this,
Preconditions.checkNotNull(broadcastStream),
broadcastStream.getBroadcastStateDescriptor());
}

在这个 BroadcastConnectedStream 类中主要的方法有:

undefined

从图中可以看到四个 process 方法和一个 transform 私有方法,其中四个 process 方法也是参数不同,最后实际调用的方法就是这个私有的 transform 方法。

QueryableStateStream 如何使用及分析

QueryableStateStream 该类代表着可查询的状态流。该类的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class QueryableStateStream<K, V> {

//要查询的状态名称
private final String queryableStateName;

//状态的 Key 序列化器
private final TypeSerializer<K> keySerializer;

//状态的 descriptor
private final StateDescriptor<?, V> stateDescriptor;

//构造器
public QueryableStateStream(String queryableStateName, StateDescriptor<?, V> stateDescriptor, TypeSerializer<K> keySerializer) {

}

//返回可以查询状态的名称
public String getQueryableStateName() {
return queryableStateName;
}

//返回 key 序列化器
public TypeSerializer<K> getKeySerializer() {
return keySerializer;
}

//返回状态的 descriptor
public StateDescriptor<?, V> getStateDescriptor() {
return stateDescriptor;
}
}

在 KeyedStream 你可以通过 asQueryableState() 方法返回一个 QueryableStateStream 数据流,这个方法可以通过传入不同的参数来实现,主要的参数就是 queryableStateName 和 StateDescriptor(这个参数你可以传入 ValueStateDescriptor、FoldingStateDescriptor 和 ReducingStateDescriptor 三种)。

具体如何使用呢,我们来看个 demo:

1
2
3
4
5
6
7
8
9
10
11
12
ValueStateDescriptor<Tuple2<Integer, Long>> valueState = new ValueStateDescriptor<>(
"any", source.getType(), null);

QueryableStateStream<Integer, Tuple2<Integer, Long>> queryableState =
source.keyBy(new KeySelector<Tuple2<Integer, Long>, Integer>() {
private static final long serialVersionUID = 7480503339992214681L;

@Override
public Integer getKey(Tuple2<Integer, Long> value) {
return value.f0;
}
}).asQueryableState("zhisheng", valueState);

小结

本节算是对 Flink DataStream 包下的所有常用的 Stream 做了个讲解,不仅从使用方式来介绍这些 Stream API 该如何使用,而且还给出了部分 demo,此外还剖析了部分 Stream 的代码结构及其内部部分方法的源码实现,从而能够让大家不仅仅是从表面上去使用这些 DataStream API,还能够对它们的实现原理有了解,这样就可以做到活学活用,并且还可以自己去做扩展。

在 3.1 节中讲解了 Flink 中的三种 Time 和其对应的使用场景,然后在 3.2 节中深入的讲解了 Flink 中窗口的机制以及 Flink 中自带的 Window 的实现原理和使用方法。如果在进行 Window 计算操作的时候,如果使用的时间是 Processing Time,那么在 Flink 消费数据的时候,它完全不需要关心的数据本身的时间,意思也就是说不需要关心数据到底是延迟数据还是乱序数据。因为 Processing Time 只是代表数据在 Flink 被处理时的时间,这个时间是顺序的。但是如果你使用的是 Event Time 的话,那么你就不得不面临着这么个问题:事件乱序 & 事件延迟。

下图表示选择 Event Time 与 Process Time 的实际效果图:

undefined

在理想的情况下,Event Time 和 Process Time 是相等的,数据发生的时间与数据处理的时间没有延迟,但是现实却仍然这么骨感,会因为各种各样的问题(网络的抖动、设备的故障、应用的异常等原因)从而导致如图中曲线一样,Process Time 总是会与 Event Time 有一些延迟。所谓乱序,其实是指 Flink 接收到的事件的先后顺序并不是严格的按照事件的 Event Time 顺序排列的。比如下图:

undefined

然而在有些场景下,其实是特别依赖于事件时间而不是处理时间,比如:

  • 错误日志的时间戳,代表着发生的错误的具体时间,开发们只有知道了这个时间戳,才能去还原那个时间点系统到底发生了什么问题,或者根据那个时间戳去关联其他的事件,找出导致问题触发的罪魁祸首
  • 设备传感器或者监控系统实时上传对应时间点的设备周围的监控情况,通过监控大屏可以实时查看,不错漏重要或者可疑的事件

这种情况下,最有意义的事件发生的顺序,而不是事件到达 Flink 后被处理的顺序。庆幸的是 Flink 支持用户以事件时间来定义窗口(也支持以处理时间来定义窗口),那么这样就要去解决上面所说的两个问题。针对上面的问题(事件乱序 & 事件延迟),Flink 引入了 Watermark 机制来解决。

Watermark 是什么?

举个例子:

统计 8:00 ~ 9:00 这个时间段打开淘宝 App 的用户数量,Flink 这边可以开个窗口做聚合操作,但是由于网络的抖动或者应用采集数据发送延迟等问题,于是无法保证在窗口时间结束的那一刻窗口中是否已经收集好了在 8:00 ~ 9:00 中用户打开 App 的事件数据,但又不能无限期的等下去?当基于事件时间的数据流进行窗口计算时,最为困难的一点也就是如何确定对应当前窗口的事件已经全部到达。然而实际上并不能百分百的准确判断,因此业界常用的方法就是基于已经收集的消息来估算是否还有消息未到达,这就是 Watermark 的思想。

Watermark 是一种衡量 Event Time 进展的机制,它是数据本身的一个隐藏属性,数据本身携带着对应的 Watermark。Watermark 本质来说就是一个时间戳,代表着比这时间戳早的事件已经全部到达窗口,即假设不会再有比这时间戳还小的事件到达,这个假设是触发窗口计算的基础,只有 Watermark 大于窗口对应的结束时间,窗口才会关闭和进行计算。按照这个标准去处理数据,那么如果后面还有比这时间戳更小的数据,那么就视为迟到的数据,对于这部分迟到的数据,Flink 也有相应的机制(下文会讲)去处理。

下面通过几个图来了解一下 Watermark 是如何工作的!

undefined

上图中的数据是 Flink 从消息队列中消费的,然后在 Flink 中有个 4s 的时间窗口(根据事件时间定义的窗口),消息队列中的数据是乱序过来的,数据上的数字代表着数据本身的 timestamp,W(4)W(9) 是水印。

undefined

经过 Flink 的消费,数据 132 进入了第一个窗口,然后 7 会进入第二个窗口,接着 3 依旧会进入第一个窗口,然后就有水印了,此时水印过来了,就会发现水印的 timestamp 和第一个窗口结束时间是一致的,那么它就表示在后面不会有比 4 还小的数据过来了,接着就会触发第一个窗口的计算操作,如下图所示:

undefined

那么接着后面的数据 56 会进入到第二个窗口里面,数据 9 会进入在第三个窗口里面。

undefined

那么当遇到水印 9 时,发现水印比第二个窗口的结束时间 8 还大,所以第二个窗口也会触发进行计算,然后以此继续类推下去。

相信看完上面几个图的讲解,你已经知道了 Watermark 的工作原理是啥了,那么在 Flink 中该如何去配置水印呢,下面一起来看看。

在 Flink 中,数据处理中需要通过调用 DataStream 中的 assignTimestampsAndWatermarks 方法来分配时间和水印,该方法可以传入两种参数,一个是 AssignerWithPeriodicWatermarks,另一个是 AssignerWithPunctuatedWatermarks。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public SingleOutputStreamOperator<T> assignTimestampsAndWatermarks(AssignerWithPeriodicWatermarks<T> timestampAndWatermarkAssigner) {

final int inputParallelism = getTransformation().getParallelism();
final AssignerWithPeriodicWatermarks<T> cleanedAssigner = clean(timestampAndWatermarkAssigner);

TimestampsAndPeriodicWatermarksOperator<T> operator = new TimestampsAndPeriodicWatermarksOperator<>(cleanedAssigner);

return transform("Timestamps/Watermarks", getTransformation().getOutputType(), operator).setParallelism(inputParallelism);
}

public SingleOutputStreamOperator<T> assignTimestampsAndWatermarks(AssignerWithPunctuatedWatermarks<T> timestampAndWatermarkAssigner) {

final int inputParallelism = getTransformation().getParallelism();
final AssignerWithPunctuatedWatermarks<T> cleanedAssigner = clean(timestampAndWatermarkAssigner);

TimestampsAndPunctuatedWatermarksOperator<T> operator = new TimestampsAndPunctuatedWatermarksOperator<>(cleanedAssigner);

return transform("Timestamps/Watermarks", getTransformation().getOutputType(), operator).setParallelism(inputParallelism);
}

所以设置 Watermark 是有如下两种方式:

  • AssignerWithPunctuatedWatermarks:数据流中每一个递增的 EventTime 都会产生一个 Watermark。

在实际的生产环境中,在 TPS 很高的情况下会产生大量的 Watermark,可能在一定程度上会对下游算子造成一定的压力,所以只有在实时性要求非常高的场景才会选择这种方式来进行水印的生成。

  • AssignerWithPeriodicWatermarks:周期性的(一定时间间隔或者达到一定的记录条数)产生一个 Watermark。

在实际的生产环境中,通常这种使用较多,它会周期性产生 Watermark 的方式,但是必须结合时间或者积累条数两个维度,否则在极端情况下会有很大的延时,所以 Watermark 的生成方式需要根据业务场景的不同进行不同的选择。

下面再分别详细讲下这两种的实现方式。

Punctuated Watermark

AssignerWithPunctuatedWatermarks 接口中包含了 checkAndGetNextWatermark 方法,这个方法会在每次 extractTimestamp() 方法被调用后调用,它可以决定是否要生成一个新的水印,返回的水印只有在不为 null 并且时间戳要大于先前返回的水印时间戳的时候才会发送出去,如果返回的水印是 null 或者返回的水印时间戳比之前的小则不会生成新的水印。

那么该怎么利用这个来定义水印生成器呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
public class WordPunctuatedWatermark implements AssignerWithPunctuatedWatermarks<Word> {

@Nullable
@Override
public Watermark checkAndGetNextWatermark(Word lastElement, long extractedTimestamp) {
return extractedTimestamp % 3 == 0 ? new Watermark(extractedTimestamp) : null;
}

@Override
public long extractTimestamp(Word element, long previousElementTimestamp) {
return element.getTimestamp();
}
}

需要注意的是这种情况下可以为每个事件都生成一个水印,但是因为水印是要在下游参与计算的,所以过多的话会导致整体计算性能下降。

3.5.4 Periodic Watermark

通常在生产环境中使用 AssignerWithPeriodicWatermarks 来定期分配时间戳并生成水印比较多,那么先来讲下这个该如何使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class WordWatermark implements AssignerWithPeriodicWatermarks<Word> {

private long currentTimestamp = Long.MIN_VALUE;

@Override
public long extractTimestamp(Word word, long previousElementTimestamp) {
if (word.getTimestamp() > currentTimestamp) {
this.currentTimestamp = word.getTimestamp();
}
return currentTimestamp;
}

@Nullable
@Override
public Watermark getCurrentWatermark() {
long maxTimeLag = 5000;
return new Watermark(currentTimestamp == Long.MIN_VALUE ? Long.MIN_VALUE : currentTimestamp - maxTimeLag);

}
}

上面的是我根据 Word 数据自定义的水印周期性生成器,在这个类中,有两个方法 extractTimestamp() 和 getCurrentWatermark()。extractTimestamp() 方法是从数据本身中提取 Event Time,该方法会返回当前时间戳与事件时间进行比较,如果事件的时间戳比 currentTimestamp 大的话,那么就将当前事件的时间戳赋值给 currentTimestamp。getCurrentWatermark() 方法是获取当前的水位线,这里有个 maxTimeLag 参数代表数据能够延迟的时间,上面代码中定义的 long maxTimeLag = 5000; 表示最大允许数据延迟时间为 5s,超过 5s 的话如果还来了之前早的数据,那么 Flink 就会丢弃了,因为 Flink 的窗口中的数据是要触发的,不可能一直在等着这些迟到的数据(由于网络的问题数据可能一直没发上来)而不让窗口触发结束进行计算操作。

通过定义这个时间,可以避免部分数据因为网络或者其他的问题导致不能够及时上传从而不把这些事件数据作为计算的,那么如果在这延迟之后还有更早的数据到来的话,那么 Flink 就会丢弃了,所以合理的设置这个允许延迟的时间也是一门细活,得观察生产环境数据的采集到消息队列再到 Flink 整个流程是否会出现延迟,统计平均延迟大概会在什么范围内波动。这也就是说明了一个事实那就是 Flink 中设计这个水印的根本目的是来解决部分数据乱序或者数据延迟的问题,而不能真正做到彻底解决这个问题,不过这一特性在相比于其他的流处理框架已经算是非常给力了。

AssignerWithPeriodicWatermarks 这个接口有四个实现类,分别如下图:

undefined

BoundedOutOfOrdernessTimestampExtractor:该类用来发出滞后于数据时间的水印,它的目的其实就是和我们上面定义的那个类作用是类似的,你可以传入一个时间代表着可以允许数据延迟到来的时间是多长。该类内部实现如下:

undefined

你可以像下面一样使用该类来分配时间和生成水印:

1
2
3
4
5
6
7
8
//Time.seconds(10) 代表允许延迟的时间大小
dataStream.assignTimestampsAndWatermarks(new BoundedOutOfOrdernessTimestampExtractor<Event>(Time.seconds(10)) {
//重写 BoundedOutOfOrdernessTimestampExtractor 中的 extractTimestamp()抽象方法
@Override
public long extractTimestamp(Event event) {
return event.getTimestamp();
}
})
  • CustomWatermarkExtractor:这是一个自定义的周期性生成水印的类,在这个类里面的数据是 KafkaEvent。
  • AscendingTimestampExtractor:时间戳分配器和水印生成器,用于时间戳单调递增的数据流,如果数据流的时间戳不是单调递增,那么会有专门的处理方法,代码如下:
1
2
3
4
5
6
7
8
9
10
public final long extractTimestamp(T element, long elementPrevTimestamp) {
final long newTimestamp = extractAscendingTimestamp(element);
if (newTimestamp >= this.currentTimestamp) {
this.currentTimestamp = ne∏wTimestamp;
return newTimestamp;
} else {
violationHandler.handleViolation(newTimestamp, this.currentTimestamp);
return newTimestamp;
}
}
  • IngestionTimeExtractor:依赖于机器系统时间,它在 extractTimestamp 和 getCurrentWatermark 方法中是根据 System.currentTimeMillis() 来获取时间的,而不是根据事件的时间,如果这个时间分配器是在数据源进 Flink 后分配的,那么这个时间就和 Ingestion Time 一致了,所以命名也取的就是叫 IngestionTimeExtractor。

注意

1、使用这种方式周期性生成水印的话,你可以通过 env.getConfig().setAutoWatermarkInterval(...); 来设置生成水印的间隔(每隔 n 毫秒)。

2、通常建议在数据源(source)之后就进行生成水印,或者做些简单操作比如 filter/map/flatMap 之后再生成水印,越早生成水印的效果会更好,也可以直接在数据源头就做生成水印。比如你可以在 source 源头类中的 run() 方法里面这样定义

1
2
3
4
5
6
7
8
9
10
11
@Override
public void run(SourceContext<MyType> ctx) throws Exception {
while (/* condition */) {
MyType next = getNext();
ctx.collectWithTimestamp(next, next.getEventTimestamp());

if (next.hasWatermarkTime()) {
ctx.emitWatermark(new Watermark(next.getWatermarkTime()));
}
}
}

每个 Kafka 分区的时间戳

当以 Kafka 来作为数据源的时候,通常每个 Kafka 分区的数据时间戳是递增的(事件是有序的),但是当你作业设置多个并行度的时候,Flink 去消费 Kafka 数据流是并行的,那么并行的去消费 Kafka 分区的数据就会导致打乱原每个分区的数据时间戳的顺序。在这种情况下,你可以使用 Flink 中的 Kafka-partition-aware 特性来生成水印,使用该特性后,水印会在 Kafka 消费端生成,然后每个 Kafka 分区和每个分区上的水印最后的合并方式和水印在数据流 shuffle 过程中的合并方式一致。

如果事件时间戳严格按照每个 Kafka 分区升序,则可以使用前面提到的 AscendingTimestampExtractor 水印生成器来为每个分区生成水印。下面代码教大家如何使用 per-Kafka-partition 来生成水印。

1
2
3
4
5
6
7
8
9
10
FlinkKafkaConsumer011<Event> kafkaSource = new FlinkKafkaConsumer011<>("zhisheng", schema, props);
kafkaSource.assignTimestampsAndWatermarks(new AscendingTimestampExtractor<Event>() {

@Override
public long extractAscendingTimestamp(Event event) {
return event.eventTimestamp();
}
});

DataStream<Event> stream = env.addSource(kafkaSource);

下图表示水印在 Kafka 分区后如何通过流数据流传播:

undefined

其实在上文中已经提到的一点是在设置 Periodic Watermark 时,是允许提供一个参数,表示数据最大的延迟时间。其实这个值要结合自己的业务以及数据的情况来设置,如果该值设置的太小会导致数据因为网络或者其他的原因从而导致乱序或者延迟的数据太多,那么最后窗口触发的时候,可能窗口里面的数据量很少,那么这样计算的结果很可能误差会很大,对于有的场景(要求正确性比较高)是不太符合需求的。但是如果该值设置的太大,那么就会导致很多窗口一直在等待延迟的数据,从而一直不触发,这样首先就会导致数据的实时性降低,另外将这么多窗口的数据存在内存中,也会增加作业的内存消耗,从而可能会导致作业发生 OOM 的问题。

综上建议:

  • 合理设置允许数据最大延迟时间
  • 不太依赖事件时间的场景就不要设置时间策略为 EventTime

延迟数据该如何处理(三种方法)

丢弃(默认)

在 Flink 中,对这么延迟数据的默认处理方式是丢弃。

allowedLateness 再次指定允许数据延迟的时间

allowedLateness 表示允许数据延迟的时间,这个方法是在 WindowedStream 中的,用来设置允许窗口数据延迟的时间,超过这个时间的元素就会被丢弃,这个的默认值是 0,该设置仅针对于以事件时间开的窗口,它的源码如下:

1
2
3
4
5
6
7
public WindowedStream<T, K, W> allowedLateness(Time lateness) {
final long millis = lateness.toMilliseconds();
checkArgument(millis >= 0, "The allowed lateness cannot be negative.");

this.allowedLateness = millis;
return this;
}

之前有多个小伙伴问过我 Watermark 中允许的数据延迟和这个数据延迟的区别是啥?我的回复是该允许延迟的时间是在 Watermark 允许延迟的基础上增加的时间。那么具体该如何使用 allowedLateness 呢。

1
2
3
4
5
6
7
dataStream.assignTimestampsAndWatermarks(new TestWatermarkAssigner())
.keyBy(new TestKeySelector())
.timeWindow(Time.milliseconds(1), Time.milliseconds(1))
.allowedLateness(Time.milliseconds(2)) //表示允许再次延迟 2 毫秒
.apply(new WindowFunction<Integer, String, Integer, TimeWindow>() {
//计算逻辑
});

sideOutputLateData 收集迟到的数据

sideOutputLateData 这个方法同样是 WindowedStream 中的方法,该方法会将延迟的数据发送到给定 OutputTag 的 side output 中去,然后你可以通过 SingleOutputStreamOperator.getSideOutput(OutputTag) 来获取这些延迟的数据。具体的操作方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//定义 OutputTag
OutputTag<Integer> lateDataTag = new OutputTag<Integer>("late"){};

SingleOutputStreamOperator<String> windowOperator = dataStream
.assignTimestampsAndWatermarks(new TestWatermarkAssigner())
.keyBy(new TestKeySelector())
.timeWindow(Time.milliseconds(1), Time.milliseconds(1))
.allowedLateness(Time.milliseconds(2))
.sideOutputLateData(lateDataTag) //指定 OutputTag
.apply(new WindowFunction<Integer, String, Integer, TimeWindow>() {
//计算逻辑
});

windowOperator.addSink(resultSink);

//通过指定的 OutputTag 从 Side Output 中获取到延迟的数据之后,你可以通过 addSink() 方法存储下来,这样可以方便你后面去排查哪些数据是延迟的。
windowOperator.getSideOutput(lateDataTag)
.addSink(lateResultSink);

小结与反思

本节讲了 Watermark 的概念,并讲解了 Flink 中自带的 Watermark,然后还教大家如何设置 Watermark 以及如何自定义 Watermark,最后通过结合 Window 与 Watermark 去处理延迟数据,还讲解了三种常见的处理延迟数据的方法。

关于 Watermark 你有遇到什么问题吗?对于延迟数据你通常是怎么处理的?

本节相关的代码地址:Watermark

通过前面我们可以知道 Flink Job 的大致结构就是 Source ——> Transformation ——> Sink

undefined

那么这个 Source 是什么意思呢?我们下面来看看。

Data Source 介绍

Data Source 是什么呢?就字面意思其实就可以知道:数据来源。

Flink 做为一款流式计算框架,它可用来做批处理,即处理静态的数据集、历史的数据集;也可以用来做流处理,即处理实时的数据流(做计算操作),然后将处理后的数据实时下发,只要数据源源不断过来,Flink 就能够一直计算下去。

Flink 中你可以使用 StreamExecutionEnvironment.addSource(sourceFunction) 来为你的程序添加数据来源。

Flink 已经提供了若干实现好了的 source function,当然你也可以通过实现 SourceFunction 来自定义非并行的 source 或者实现 ParallelSourceFunction 接口或者扩展 RichParallelSourceFunction 来自定义并行的 source。

那么常用的 Data Source 有哪些呢?

常用的 Data Source

StreamExecutionEnvironment 中可以使用以下这些已实现的 stream source。

undefined

总的来说可以分为下面几大类:

基于集合

  1. fromCollection(Collection) - 从 Java 的 Java.util.Collection 创建数据流。集合中的所有元素类型必须相同。
  2. fromCollection(Iterator, Class) - 从一个迭代器中创建数据流。Class 指定了该迭代器返回元素的类型。
  3. fromElements(T …) - 从给定的对象序列中创建数据流。所有对象类型必须相同。
1
2
3
4
5
6
7
8
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

DataStream<Event> input = env.fromElements(
new Event(1, "barfoo", 1.0),
new Event(2, "start", 2.0),
new Event(3, "foobar", 3.0),
...
);
  1. fromParallelCollection(SplittableIterator, Class) - 从一个迭代器中创建并行数据流。Class 指定了该迭代器返回元素的类型。
  2. generateSequence(from, to) - 创建一个生成指定区间范围内的数字序列的并行数据流。

基于文件

1、readTextFile(path) - 读取文本文件,即符合 TextInputFormat 规范的文件,并将其作为字符串返回。

1
2
3
final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

DataStream<String> text = env.readTextFile("file:///path/to/file");

2、readFile(fileInputFormat, path) - 根据指定的文件输入格式读取文件(一次)。

3、readFile(fileInputFormat, path, watchType, interval, pathFilter, typeInfo) - 这是上面两个方法内部调用的方法。它根据给定的 fileInputFormat 和读取路径读取文件。根据提供的 watchType,这个 source 可以定期(每隔 interval 毫秒)监测给定路径的新数据(FileProcessingMode.PROCESSCONTINUOUSLY),或者处理一次路径对应文件的数据并退出(FileProcessingMode.PROCESSONCE)。你可以通过 pathFilter 进一步排除掉需要处理的文件。

1
2
3
4
5
final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

DataStream<MyEvent> stream = env.readFile(
myFormat, myFilePath, FileProcessingMode.PROCESS_CONTINUOUSLY, 100,
FilePathFilter.createDefaultFilter(), typeInfo);

实现:

在具体实现上,Flink 把文件读取过程分为两个子任务,即目录监控和数据读取。每个子任务都由单独的实体实现。目录监控由单个非并行(并行度为1)的任务执行,而数据读取由并行运行的多个任务执行。后者的并行性等于作业的并行性。单个目录监控任务的作用是扫描目录(根据 watchType 定期扫描或仅扫描一次),查找要处理的文件并把文件分割成切分片(splits),然后将这些切分片分配给下游 reader。reader 负责读取数据。每个切分片只能由一个 reader 读取,但一个 reader 可以逐个读取多个切分片。

重要注意:

如果 watchType 设置为 FileProcessingMode.PROCESS_CONTINUOUSLY,则当文件被修改时,其内容将被重新处理。这会打破“exactly-once”语义,因为在文件末尾附加数据将导致其所有内容被重新处理。

如果 watchType 设置为 FileProcessingMode.PROCESS_ONCE,则 source 仅扫描路径一次然后退出,而不等待 reader 完成文件内容的读取。当然 reader 会继续阅读,直到读取所有的文件内容。关闭 source 后就不会再有检查点。这可能导致节点故障后的恢复速度较慢,因为该作业将从最后一个检查点恢复读取。

基于 Socket

socketTextStream(String hostname, int port) - 从 socket 读取。元素可以用分隔符切分。

1
2
3
4
5
6
7
8
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

DataStream<Tuple2<String, Integer>> dataStream = env
.socketTextStream("localhost", 9999) // 监听 localhost 的 9999 端口过来的数据
.flatMap(new Splitter())
.keyBy(0)
.timeWindow(Time.seconds(5))
.sum(1);

自定义

addSource - 添加一个新的 source function。例如,你可以用 addSource(new FlinkKafkaConsumer011<>(…)) 从 Apache Kafka 读取数据。

说说上面几种的特点

  1. 基于集合:有界数据集,更偏向于本地测试用
  2. 基于文件:适合监听文件修改并读取其内容
  3. 基于 Socket:监听主机的 host port,从 Socket 中获取数据
  4. 自定义 addSource:大多数的场景数据都是无界的,会源源不断过来。比如去消费 Kafka 某个 topic 上的数据,这时候就需要用到这个 addSource,可能因为用的比较多的原因吧,Flink 直接提供了 FlinkKafkaConsumer011 等类可供你直接使用。你可以去看看 FlinkKafkaConsumerBase 这个基础类,它是 Flink Kafka 消费的最根本的类。
1
2
3
4
5
6
7
8
9
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

DataStream<KafkaEvent> input = env
.addSource(
new FlinkKafkaConsumer011<>(
parameterTool.getRequired("input-topic"), //从参数中获取传进来的 topic
new KafkaEventSchema(),
parameterTool.getProperties())
.assignTimestampsAndWatermarks(new CustomWatermarkExtractor()));

Flink 目前支持如下面常见的 Source:

undefined

如果你想自定义自己的 Source 呢?在后面 3.8 节会讲解。

Data Sink 介绍

首先 Sink 的意思是:

undefined

大概可以猜到了吧!Data sink 有点把数据存储下来(落库)的意思。Flink 在拿到数据后做一系列的计算后,最后要将计算的结果往下游发送。比如将数据存储到 MySQL、ElasticSearch、Cassandra,或者继续发往 Kafka、 RabbitMQ 等消息队列,更或者直接调用其他的第三方应用服务(比如告警)。

常用的 Data Sink

上面介绍了 Flink Data Source 有哪些,这里也看看 Flink Data Sink 支持的有哪些。

undefined

再看下源码有哪些呢?

undefined

可以看到有 Kafka、ElasticSearch、Socket、RabbitMQ、JDBC、Cassandra POJO、File、Print 等 Sink 的方式。

可能自带的这些 Sink 不支持你的业务场景,那么你也可以自定义符合自己公司业务需求的 Sink,同样在后面 3.8 节将教会大家。

小结与反思

本节讲了 Flink 中常用的 Connector,包括 Source 和 Sink 的,其中每种都有很多 Connector,大家可以根据实际场景去使用合适的 Connector。

在前面 3.6 节中介绍了 Flink 中的 Data Source 和 Data Sink,然后还讲诉了自带的一些 Source 和 Sink 的 Connector。本篇文章将讲解一下用的最多的 Connector —— Kafka,带大家利用 Kafka Connector 读取 Kafka 数据,做一些计算操作后然后又通过 Kafka Connector 写入到 kafka 消息队列去。

undefined

准备环境和依赖

环境安装和启动

如果你已经安装好了 Flink 和 Kafka,那么接下来使用命令运行启动 Flink、Zookepeer、Kafka 就行了。

undefined

undefined

执行命令都启动好了后就可以添加依赖了。

添加 maven 依赖

Flink 里面支持 Kafka 0.8.x 以上的版本,具体采用哪个版本的 Maven 依赖需要根据安装的 Kafka 版本来确定。因为之前我们安装的 Kafka 是 1.1.0 版本,所以这里我们选择的 Kafka Connector 为 flink-connector-kafka-0.11_2.11 (支持 Kafka 0.11.x 版本及以上,该 Connector 支持 Kafka 事务消息传递,所以能保证 Exactly Once)。

1
2
3
4
5
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-connector-kafka-0.11_2.11</artifactId>
<version>${flink.version}</version>
</dependency>

Flink、Kafka、Flink Kafka Connector 三者对应的版本可以根据 官网 的对比来选择。需要注意的是 flink-connector-kafka_2.11 这个版本支持的 Kafka 版本要大于 1.0.0,从 Flink 1.9 版本开始,它使用的是 Kafka 2.2.0 版本的客户端,虽然这些客户端会做向后兼容,但是建议还是按照官网约定的来规范使用 Connector 版本。另外你还要添加的依赖有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<!--flink java-->
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-java</artifactId>
<version>${flink.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-streaming-java_${scala.binary.version}</artifactId>
<version>${flink.version}</version>
<scope>provided</scope>
</dependency>

<!--log-->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-log4j12</artifactId>
<version>1.7.7</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>log4j</groupId>
<artifactId>log4j</artifactId>
<version>1.2.17</version>
<scope>runtime</scope>
</dependency>

<!--alibaba fastjson-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.51</version>
</dependency>

测试数据发到 Kafka Topic

实体类,Metric.java

1
2
3
4
5
6
7
8
9
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Metric {
public String name;
public long timestamp;
public Map<String, Object> fields;
public Map<String, String> tags;
}

往 kafka 中写数据工具类:KafkaUtils.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/**
* 往kafka中写数据,可以使用这个main函数进行测试一下
*/
public class KafkaUtils {
public static final String broker_list = "localhost:9092";
public static final String topic = "metric"; // kafka topic,Flink 程序中需要和这个统一

public static void writeToKafka() throws InterruptedException {
Properties props = new Properties();
props.put("bootstrap.servers", broker_list);
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer"); //key 序列化
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer"); //value 序列化
KafkaProducer producer = new KafkaProducer<String, String>(props);

Metric metric = new Metric();
metric.setTimestamp(System.currentTimeMillis());
metric.setName("mem");
Map<String, String> tags = new HashMap<>();
Map<String, Object> fields = new HashMap<>();

tags.put("cluster", "zhisheng");
tags.put("host_ip", "101.147.022.106");

fields.put("used_percent", 90d);
fields.put("max", 27244873d);
fields.put("used", 17244873d);
fields.put("init", 27244873d);

metric.setTags(tags);
metric.setFields(fields);

ProducerRecord record = new ProducerRecord<String, String>(topic, null, null, JSON.toJSONString(metric));
producer.send(record);
System.out.println("发送数据: " + JSON.toJSONString(metric));

producer.flush();
}

public static void main(String[] args) throws InterruptedException {
while (true) {
Thread.sleep(300);
writeToKafka();
}
}
}

运行:

undefined

如果出现如上图标记的,即代表能够不断往 kafka 发送数据的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Main {
public static void main(String[] args) throws Exception {
final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("zookeeper.connect", "localhost:2181");
props.put("group.id", "metric-group");
props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer"); //key 反序列化
props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
props.put("auto.offset.reset", "latest"); //value 反序列化

DataStreamSource<String> dataStreamSource = env.addSource(new FlinkKafkaConsumer011<>(
"metric", //kafka topic
new SimpleStringSchema(), // String 序列化
props)).setParallelism(1);

dataStreamSource.print(); //把从 kafka 读取到的数据打印在控制台

env.execute("Flink add data source");
}
}

运行起来:

undefined

看到没程序,Flink 程序控制台能够源源不断地打印数据呢。

代码分析

使用 FlinkKafkaConsumer011 时传入了三个参数。

  • Kafka topic:这个代表了 Flink 要消费的是 Kafka 哪个 Topic,如果你要同时消费多个 Topic 的话,那么你可以传入一个 Topic List 进去,另外也支持正则表达式匹配 Topic
  • 序列化:上面代码我们使用的是 SimpleStringSchema
  • 配置属性:将 Kafka 等的一些配置传入

undefined

前面演示了 Flink 如何消费 Kafak 数据,接下来演示如何把其他 Kafka 集群中 topic 数据原样写入到自己本地起的 Kafka 中去。

配置文件

1
2
3
4
5
6
7
8
9
10
11
//其他 Kafka 集群配置
kafka.brokers=xxx:9092,xxx:9092,xxx:9092
kafka.group.id=metrics-group-test
kafka.zookeeper.connect=xxx:2181
metrics.topic=xxx
stream.parallelism=5
kafka.sink.brokers=localhost:9092
kafka.sink.topic=metric-test
stream.checkpoint.interval=1000
stream.checkpoint.enable=false
stream.sink.parallelism=5

目前我们先看下本地 Kafka 是否有这个 metric-test topic 呢?需要执行下这个命令:

1
bin/kafka-topics.sh --list --zookeeper localhost:2181

undefined

程序代码

Main.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Main {
public static void main(String[] args) throws Exception{
final ParameterTool parameterTool = ExecutionEnvUtil.createParameterTool(args);
StreamExecutionEnvironment env = ExecutionEnvUtil.prepare(parameterTool);
DataStreamSource<Metrics> data = KafkaConfigUtil.buildSource(env);

data.addSink(new FlinkKafkaProducer011<Metrics>(
parameterTool.get("kafka.sink.brokers"),
parameterTool.get("kafka.sink.topic"),
new MetricSchema()
)).name("flink-connectors-kafka")
.setParallelism(parameterTool.getInt("stream.sink.parallelism"));

env.execute("flink learning connectors kafka");
}
}

运行结果

启动程序,查看运行结果,不段执行上面命令,查看是否有新的 topic 出来:

undefined

执行命令可以查看该 topic 的信息:

1
bin/kafka-topics.sh --describe --zookeeper localhost:2181 --topic metric-test

undefined

前面代码使用的 FlinkKafkaProducer011 只传了三个参数:brokerList、topicId、serializationSchema(序列化),其实是支持传入多个参数的。

undefined

FlinkKafkaConsumer 源码剖析

FlinkKafkaConsumer 的继承关系如下图所示。

undefined

可以发现几个版本的 FlinkKafkaConsumer 都继承自 FlinkKafkaConsumerBase 抽象类,所以可知 FlinkKafkaConsumerBase 是最核心的类了。FlinkKafkaConsumerBase 实现了 CheckpointedFunction、CheckpointListener 接口,继承了 RichParallelSourceFunction 抽象类来读取 Kafka 数据。

undefined

在 FlinkKafkaConsumerBase 中的 open 方法中做了大量的配置初始化工作,然后在 run 方法里面是由 AbstractFetcher 来获取数据的,在 AbstractFetcher 中有用 List> 来存储着所有订阅分区的状态信息,包括了下面这些字段:

1
2
3
4
private final KafkaTopicPartition partition;    //分区
private final KPH kafkaPartitionHandle;
private volatile long offset; //消费到的 offset
private volatile long committedOffset; //提交的 offset

在 FlinkKafkaConsumerBase 中还有字段定义 Flink 自动发现 Kafka 主题和分区个数的时间,默认是不开启的(时间为 Long.MIN_VALUE),像如果传入的是正则表达式参数,那么动态的发现主题还是有意义的,如果配置的已经是固定的 Topic,那么完全就没有开启这个的必要,另外就是 Kafka 的分区个数的自动发现,像高峰流量的时期,如果 Kafka 的分区扩容了,但是在 Flink 这边没有配置这个参数那就会导致 Kafka 新分区中的数据不会被消费到,这个参数由 flink.partition-discovery.interval-millis 控制。

FlinkKafkaProducer 源码剖析

FlinkKafkaProducer 这个有些特殊,不同版本的类结构有些不一样,如 FlinkKafkaProducer011 是继承的 TwoPhaseCommitSinkFunction 抽象类,而 FlinkKafkaProducer010 和 FlinkKafkaProducer09 是基于 FlinkKafkaProducerBase 类来实现的。

undefined

undefined

在 Kafka 0.11.x 版本后支持了事务,这让 Flink 与 Kafka 的事务相结合从而实现端到端的 Exactly once 才有了可能,在 9.5 节中会详细讲解如何利用 TwoPhaseCommitSinkFunction 来实现 Exactly once 的。

数据 Sink 到下游的 Kafka,可你能会关心数据的分区策略,在 Flink 中自带了一种就是 FlinkFixedPartitioner,它使用的是 round-robin 策略进行下发到下游 Kafka Topic 的分区上的,当然也提供了 FlinkKafkaPartitioner 接口供你去实现自定义的分区策略。

如何消费多个 Kafka Topic

通常可能会有很多类型的数据全部发到 Kafka,但是发送的数据却不是在同一个 Topic 里面,然后在 Flink 处消费的时候,又要去同时消费这些多个 Topic,在 Flink 中除了支持可以消费单个 Topic 的数据,还支持传入多个 Topic,另外还支持 Topic 的正则表达式(因为有时候可能会事先不确定到底会有多少个 Topic,所以使用正则来处理会比较好,只要在 Kafka 建立的 Topic 名是有规律的就行),如下几种构造器可以传入不同参数来创建 FlinkKafkaConsumer 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//单个 Topic
public FlinkKafkaConsumer011(String topic, DeserializationSchema<T> valueDeserializer, Properties props) {
this(Collections.singletonList(topic), valueDeserializer, props);
}

//多个 Topic
public FlinkKafkaConsumer011(List<String> topics, DeserializationSchema<T> deserializer, Properties props) {
this(topics, new KafkaDeserializationSchemaWrapper<>(deserializer), props);
}

//正则表达式 Topic
public FlinkKafkaConsumer011(Pattern subscriptionPattern, DeserializationSchema<T> valueDeserializer, Properties props) {
this(subscriptionPattern, new KafkaDeserializationSchemaWrapper<>(valueDeserializer), props);
}

想要获取数据的元数据信息

在消费 Kafka 数据的时候,有时候想获取到数据是从哪个 Topic、哪个分区里面过来的,这条数据的 offset 值是多少。这些元数据信息在有的场景真的需要,那么这种场景下该如何获取呢?其实在获取数据进行反序列化的时候使用 KafkaDeserializationSchema 就行。

1
2
3
4
5
6
public interface KafkaDeserializationSchema<T> extends Serializable, ResultTypeQueryable<T> {

boolean isEndOfStream(T nextElement);

T deserialize(ConsumerRecord<byte[], byte[]> record) throws Exception;
}

在 KafkaDeserializationSchema 接口中的 deserialize 方法里面的 ConsumerRecord 类中是包含了数据的元数据信息。

1
2
3
4
5
6
7
8
9
10
11
12
public class ConsumerRecord<K, V> {
private final String topic;
private final int partition;
private final long offset;
private final long timestamp;
private final TimestampType timestampType;
private final long checksum;
private final int serializedKeySize;
private final int serializedValueSize;
private final K key;
private final V value;
}

所在在使用 FlinkKafkaConsumer011 构造对象的的时候可以传入实现 KafkaDeserializationSchema 接口后的参数对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//单个 Topic
public FlinkKafkaConsumer011(String topic, KafkaDeserializationSchema<T> deserializer, Properties props) {
this(Collections.singletonList(topic), deserializer, props);
}

//多个 Topic
public FlinkKafkaConsumer011(List<String> topics, KafkaDeserializationSchema<T> deserializer, Properties props) {
super(topics, deserializer, props);
}

//正则表达式 Topic
public FlinkKafkaConsumer011(Pattern subscriptionPattern, KafkaDeserializationSchema<T> deserializer, Properties props) {
super(subscriptionPattern, deserializer, props);
}

多种数据类型

因为在 Kafka 的数据的类型可能会有很多种类型,比如是纯 String、String 类型的 JSON、Avro、Protobuf。那么源数据类型不同,在消费 Kafka 的时候反序列化也是会有一定的不同,但最终还是依赖前面的 KafkaDeserializationSchema 或者 DeserializationSchema (反序列化的 Schema),数据经过处理后的结果再次发到 Kafka 数据类型也是会有多种,它依赖的是 SerializationSchema(序列化的 Schema)。

序列化失败

因为数据是从 Kafka 过来的,难以避免的是 Kafka 中的数据可能会出现 null 或者不符合预期规范的数据,然后在反序列化的时候如果作业里面没有做异常处理的话,就会导致作业失败重启,这样情况可以在反序列化处做异常处理,保证作业的健壮性。

Kafka 消费 Offset 的选择

因为在 Flink Kafka Consumer 中是支持配置如何确定从 Kafka 分区开始消费的起始位置的。

1
2
3
4
5
FlinkKafkaConsumer011<String> myConsumer = new FlinkKafkaConsumer0111<>(...);
consumer.setStartFromEarliest(); //从最早的数据开始消费
consumer.setStartFromLatest(); //从最新的数据开始消费
consumer.setStartFromTimestamp(...); //从根据指定的时间戳(ms)处开始消费
consumer.setStartFromGroupOffsets(); //默认从提交的 offset 开始消费

另外还支持根据分区指定的 offset 去消费 Topic 数据,示例如下:

1
2
3
4
5
6
Map<KafkaTopicPartition, Long> specificStartOffsets = new HashMap<>();
specificStartOffsets.put(new KafkaTopicPartition("zhisheng", 0), 23L);
specificStartOffsets.put(new KafkaTopicPartition("zhisheng", 1), 31L);
specificStartOffsets.put(new KafkaTopicPartition("zhisheng", 2), 43L);

myConsumer.setStartFromSpecificOffsets(specificStartOffsets);

注意:这种情况下如果该分区中不存在指定的 Offset 了,则会使用默认的 setStartFromGroupOffsets 来消费分区中的数据。如果作业是从 Checkpoint 或者 Savepoint 还原的,那么上面这些配置无效,作业会根据状态中存储的 Offset 为准,然后开始消费。

上面这几种策略是支持可以配置的,需要在作业中指定,具体选择哪种是需要根据作业的业务需求来判断的。

小结与反思

本节讲了 Flink 中最常使用的 Connector —— Kafka,该 Connector 不仅可以作为 Source,还可以作为 Sink。通过了完成的案例讲解从 Kafka 读取数据和写入数据到 Kafka,并分析了这两个的主要类的结构。最后讲解了使用该 Connector 可能会遇到的一些问题,该如何去解决这些问题。

你在公司使用该 Connector 的过程中有遇到什么问题吗?是怎么解决的呢?还有什么问题要补充?

本节涉及代码地址:https://github.com/zhisheng17/flink-learning/tree/master/flink-learning-connectors/flink-learning-connectors-kafka

在前面文章 3.6 节中讲解了 Flink 中的 Data Source 和 Data Sink,然后介绍了 Flink 中自带的一些 Source 和 Sink 的 Connector,接着我们还有几篇实战会讲解了如何从 Kafka 处理数据写入到 Kafka、ElasticSearch 等,当然 Flink 还有一些其他的 Connector,我们这里就不一一介绍了,大家如果感兴趣的话可以去官网查看一下,如果对其代码实现比较感兴趣的话,也可以去看看其源码的实现。我们这篇文章来讲解一下如何自定义 Source 和 Sink Connector?这样我们后面再遇到什么样的需求都难不倒我们了。

如何自定义 Source Connector?

这里就演示一下如何自定义 Source 从 MySQL 中读取数据。

添加依赖

在 pom.xml 中添加 MySQL 依赖:

1
2
3
4
5
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.34</version>
</dependency>

数据库建表

数据库建表如下:

1
2
3
4
5
6
7
8
DROP TABLE IF EXISTS `student`;
CREATE TABLE `student` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(25) COLLATE utf8_bin DEFAULT NULL,
`password` varchar(25) COLLATE utf8_bin DEFAULT NULL,
`age` int(10) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8 COLLATE=utf8_bin;

数据库插入数据

1
2
INSERT INTO `student` VALUES ('1', 'zhisheng01', '123456', '18'), ('2', 'zhisheng02', '123', '17'), ('3', 'zhisheng03', '1234', '18'), ('4', 'zhisheng04', '12345', '16');
COMMIT;

新建实体类

1
2
3
4
5
6
7
8
9
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Student {
public int id;
public String name;
public String password;
public int age;
}

自定义 Source 类

SourceFromMySQL 是自定义的 Source 类,该类继承 RichSourceFunction,实现里面的 open、close、run、cancel 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
public class SourceFromMySQL extends RichSourceFunction<Student> {
PreparedStatement ps;
private Connection connection;

/**
* open() 方法中建立连接,这样不用每次 invoke 的时候都要建立连接和释放连接。
*
* @param parameters
* @throws Exception
*/
@Override
public void open(Configuration parameters) throws Exception {
super.open(parameters);
connection = getConnection();
String sql = "select * from Student;";
ps = this.connection.prepareStatement(sql);
}

/**
* 程序执行完毕就可以进行,关闭连接和释放资源的动作了
*
* @throws Exception
*/
@Override
public void close() throws Exception {
super.close();
if (connection != null) { //关闭连接和释放资源
connection.close();
}
if (ps != null) {
ps.close();
}
}

/**
* DataStream 调用一次 run() 方法用来获取数据
*
* @param ctx
* @throws Exception
*/
@Override
public void run(SourceContext<Student> ctx) throws Exception {
ResultSet resultSet = ps.executeQuery();
while (resultSet.next()) {
Student student = new Student(
resultSet.getInt("id"),
resultSet.getString("name").trim(),
resultSet.getString("password").trim(),
resultSet.getInt("age"));
ctx.collect(student);
}
}

@Override
public void cancel() {
}

private static Connection getConnection() {
Connection con = null;
try {
Class.forName("com.mysql.jdbc.Driver");
con = DriverManager.getConnection("jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=UTF-8", "root", "123456");
} catch (Exception e) {
System.out.println("mysql get connection has exception , msg = " + e.getMessage());
}
return con;
}
}
1
2
3
4
5
6
7
8
9
public class Main2 {
public static void main(String[] args) throws Exception {
final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

env.addSource(new SourceFromMySQL()).print();

env.execute("Flink add data sourc");
}
}

运行 Flink 程序,控制台日志中可以看见打印的 student 信息。

undefined

RichSourceFunction 使用及源码分析

从上面自定义的 Source 可以看到我们继承的就是这个 RichSourceFunction 类,其实也是可以使用 SourceFunction 函数来自定义 Source。 RichSourceFunction 函数比 SourceFunction 多了 open 方法(可以用来初始化)和获取应用上下文的方法,那么来了解一下该类。

undefined

它是一个抽象类,继承自 AbstractRichFunction,实现了 SourceFunction 接口,其子类有三个,两个是抽象类,在此基础上提供了更具体的实现,另一个是 ContinuousFileMonitoringFunction。

undefined

  • MessageAcknowledgingSourceBase :它针对的是数据源是消息队列的场景并且提供了基于 ID 的应答机制。
  • MultipleIdsMessageAcknowledgingSourceBase : 在 MessageAcknowledgingSourceBase 的基础上针对 ID 应答机制进行了更为细分的处理,支持两种 ID 应答模型:session id 和 unique message id。
  • ContinuousFileMonitoringFunction:这是单个(非并行)监视任务,它接受 FileInputFormat,并且根据 FileProcessingMode 和 FilePathFilter,它负责监视用户提供的路径;决定应该进一步读取和处理哪些文件;创建与这些文件对应的 FileInputSplit 拆分,将它们分配给下游任务以进行进一步处理。

除了上面使用 RichSourceFunction 和 SourceFunction 来自定义 Source,还可以继承 RichParallelSourceFunction 抽象类或实现 ParallelSourceFunction 接口来实现自定义 Source 函数。

如何自定义 Sink Connector?

下面将写一个 demo 教大家将从 Kafka Source 的数据 Sink 到 MySQL 中去

工具类

写了一个工具类往 Kafka 的 topic 中发送数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
* 往kafka中写数据,可以使用这个main函数进行测试一下
*/
public class KafkaUtils2 {
public static final String broker_list = "localhost:9092";
public static final String topic = "student"; //kafka topic 需要和 flink 程序用同一个 topic

public static void writeToKafka() throws InterruptedException {
Properties props = new Properties();
props.put("bootstrap.servers", broker_list);
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
KafkaProducer producer = new KafkaProducer<String, String>(props);

for (int i = 1; i <= 100; i++) {
Student student = new Student(i, "zhisheng" + i, "password" + i, 18 + i);
ProducerRecord record = new ProducerRecord<String, String>(topic, null, null, JSON.toJSONString(student));
producer.send(record);
System.out.println("发送数据: " + JSON.toJSONString(student));
}
producer.flush();
}

public static void main(String[] args) throws InterruptedException {
writeToKafka();
}
}

SinkToMySQL

该类就是 Sink Function,继承了 RichSinkFunction ,然后重写了里面的方法,在 invoke 方法中将数据插入到 MySQL 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
public class SinkToMySQL extends RichSinkFunction<Student> {
PreparedStatement ps;
private Connection connection;

/**
* open() 方法中建立连接,这样不用每次 invoke 的时候都要建立连接和释放连接
*
* @param parameters
* @throws Exception
*/
@Override
public void open(Configuration parameters) throws Exception {
super.open(parameters);
connection = getConnection();
String sql = "insert into Student(id, name, password, age) values(?, ?, ?, ?);";
ps = this.connection.prepareStatement(sql);
}

@Override
public void close() throws Exception {
super.close();
//关闭连接和释放资源
if (connection != null) {
connection.close();
}
if (ps != null) {
ps.close();
}
}

/**
* 每条数据的插入都要调用一次 invoke() 方法
*
* @param value
* @param context
* @throws Exception
*/
@Override
public void invoke(Student value, Context context) throws Exception {
//组装数据,执行插入操作
ps.setInt(1, value.getId());
ps.setString(2, value.getName());
ps.setString(3, value.getPassword());
ps.setInt(4, value.getAge());
ps.executeUpdate();
}

private static Connection getConnection() {
Connection con = null;
try {
Class.forName("com.mysql.jdbc.Driver");
con = DriverManager.getConnection("jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=UTF-8", "root", "root123456");
} catch (Exception e) {
System.out.println("-----------mysql get connection has exception , msg = "+ e.getMessage());
}
return con;
}
}

这里的 source 是从 Kafka 读取数据的,然后 Flink 从 Kafka 读取到数据(JSON)后用阿里 fastjson 来解析成 Student 对象,然后在 addSink 中使用我们创建的 SinkToMySQL,这样就可以把数据存储到 MySQL 了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Main3 {
public static void main(String[] args) throws Exception {
final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("zookeeper.connect", "localhost:2181");
props.put("group.id", "metric-group");
props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
props.put("auto.offset.reset", "latest");

SingleOutputStreamOperator<Student> student = env.addSource(new FlinkKafkaConsumer011<>(
"student", //这个 kafka topic 需要和上面的工具类的 topic 一致
new SimpleStringSchema(),
props)).setParallelism(1)
.map(string -> JSON.parseObject(string, Student.class)); //Fastjson 解析字符串成 student 对象

student.addSink(new SinkToMySQL()); //数据 sink 到 mysql

env.execute("Flink add sink");
}
}

结果

运行 Flink 程序,然后再运行 KafkaUtils2.java 工具类,这样就可以了。

如果数据插入成功了,那么查看下我们的数据库:

undefined

数据库中已经插入了 100 条我们从 Kafka 发送的数据了。证明我们的 SinkToMySQL 起作用了。

RichSinkFunction 使用及源码分析

通过上面的 demo 可以发现继承 RichSinkFunction 类,然后实现内部的 open、close、invoke 方法就可以实现自定义 Sink 了,RichSinkFunction 的类图如下。

undefined

该类继承了 AbstractRichFunction 抽象类,实现了 SinkFunction 接口,同样该类也是一个 Rich 函数,它比 SinkFunction 多了 open(可以初始化数据) 和 getRuntimeContext(可以获取上下文)方法,如果不需要这两个方法,同样也是可以实现 SinkFunction 接口来自定义 Sink 的。

小结与反思

本节讲了 Flink 中该如何去自定义 Connector,包括 Source 和 Sink,每种也都有提供样例去教大家如何操作。

本节相关代码链接:

准备环境和依赖

ElasticSearch 安装

因为在 2.1 节中已经讲过 ElasticSearch 的安装,这里就不做过多的重复,需要注意的一点就是 Flink 的 ElasticSearch Connector 是区分版本号的。

undefined

所以添加依赖的时候要区分一下,根据你安装的 ElasticSearch 来选择不一样的版本依赖,另外就是不同版本的 ElasticSearch 还会导致下面的数据写入到 ElasticSearch 中出现一些不同,我们这里使用的版本是 ElasticSearch6,如果你使用的是其他的版本可以参考官网的实现。

添加依赖

1
2
3
4
5
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-connector-elasticsearch6_${scala.binary.version}</artifactId>
<version>${flink.version}</version>
</dependency>

上面这依赖版本号请自己根据使用的版本对应改变下。

ESSinkUtil 工具类

这个工具类是自己封装的,getEsAddresses 方法将传入的配置文件 es 地址解析出来,可以是域名方式,也可以是 ip + port 形式。addSink 方法是利用了 Flink 自带的 ElasticsearchSink 来封装了一层,传入了一些必要的调优参数和 es 配置参数,下面章节还会再讲些其他的配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public class ESSinkUtil {
/**
* es sink
*
* @param hosts es hosts
* @param bulkFlushMaxActions bulk flush size
* @param parallelism 并行数
* @param data 数据
* @param func
* @param <T>
*/
public static <T> void addSink(List<HttpHost> hosts, int bulkFlushMaxActions, int parallelism,
SingleOutputStreamOperator<T> data, ElasticsearchSinkFunction<T> func) {
ElasticsearchSink.Builder<T> esSinkBuilder = new ElasticsearchSink.Builder<>(hosts, func);
esSinkBuilder.setBulkFlushMaxActions(bulkFlushMaxActions);
data.addSink(esSinkBuilder.build()).setParallelism(parallelism);
}

/**
* 解析配置文件的 es hosts
*
* @param hosts
* @return
* @throws MalformedURLException
*/
public static List<HttpHost> getEsAddresses(String hosts) throws MalformedURLException {
String[] hostList = hosts.split(",");
List<HttpHost> addresses = new ArrayList<>();
for (String host : hostList) {
if (host.startsWith("http")) {
URL url = new URL(host);
addresses.add(new HttpHost(url.getHost(), url.getPort()));
} else {
String[] parts = host.split(":", 2);
if (parts.length > 1) {
addresses.add(new HttpHost(parts[0], Integer.parseInt(parts[1])));
} else {
throw new MalformedURLException("invalid elasticsearch hosts format");
}
}
}
return addresses;
}
}

Main 启动类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class Sink2ES6Main {
public static void main(String[] args) throws Exception {
//获取所有参数
final ParameterTool parameterTool = ExecutionEnvUtil.createParameterTool(args);
//准备好环境
StreamExecutionEnvironment env = ExecutionEnvUtil.prepare(parameterTool);
//从kafka读取数据
DataStreamSource<Metrics> data = KafkaConfigUtil.buildSource(env);

//从配置文件中读取 es 的地址
List<HttpHost> esAddresses = ESSinkUtil.getEsAddresses(parameterTool.get(ELASTICSEARCH_HOSTS));
//从配置文件中读取 bulk flush size,代表一次批处理的数量,这个可是性能调优参数,特别提醒
int bulkSize = parameterTool.getInt(ELASTICSEARCH_BULK_FLUSH_MAX_ACTIONS, 40);
//从配置文件中读取并行 sink 数,这个也是性能调优参数,特别提醒,这样才能够更快的消费,防止 kafka 数据堆积
int sinkParallelism = parameterTool.getInt(STREAM_SINK_PARALLELISM, 5);

//自己再自带的 es sink 上一层封装了下
ESSinkUtil.addSink(esAddresses, bulkSize, sinkParallelism, data,
(Metrics metric, RuntimeContext runtimeContext, RequestIndexer requestIndexer) -> {
requestIndexer.add(Requests.indexRequest()
.index(ZHISHENG + "_" + metric.getName()) //es 索引名
.type(ZHISHENG) //es type
.source(GsonUtil.toJSONBytes(metric), XContentType.JSON));
});
env.execute("flink learning connectors es6");
}
}

配置文件

配置都支持集群模式填写,注意用 , 分隔!

1
2
3
4
5
6
7
8
9
10
kafka.brokers=localhost:9092
kafka.group.id=zhisheng-metrics-group-test
kafka.zookeeper.connect=localhost:2181
metrics.topic=zhisheng-metrics
stream.parallelism=5
stream.checkpoint.interval=1000
stream.checkpoint.enable=false
elasticsearch.hosts=localhost:9200
elasticsearch.bulk.flush.max.actions=40
stream.sink.parallelism=5

验证数据是否写入 ElasticSearch?

执行 Main 类的 main 方法,我们的程序是只打印 Flink 的日志,没有打印存入的日志(因为我们这里没有打日志):

undefined

所以看起来不知道我们的 Sink 是否有用,数据是否从 Kafka 读取出来后存入到 ES 了。你可以查看下本地起的 ES 终端或者服务器的 ES 日志就可以看到效果了。ES 日志如下:

undefined

上图是我本地 Mac 电脑终端的 ES 日志,可以看到我们的索引了。如果还不放心,你也可以在你的电脑装个 Kibana,然后更加的直观查看下 ES 的索引情况(或者直接敲 ES 的命令)。我们用 Kibana 查看存入 ES 的索引如下:

undefined

程序执行了一会,存入 ES 的数据量就很大了。

如何保证在海量数据实时写入下 ElasticSearch 的稳定性?

上面代码已经可以实现你的大部分场景了,但是如果你的业务场景需要保证数据的完整性(不能出现丢数据的情况),那么就需要添加一些重试策略,因为在我们的生产环境中,很有可能会因为某些组件不稳定性导致各种问题,所以这里我们就要在数据存入失败的时候做重试操作,这个 Flink 自带的 es sink 就支持了,常用的失败重试配置有:

1
2
3
4
5
6
7
8
9
10
11
12
13
1. bulk.flush.backoff.enable 用来表示是否开启重试机制

2. bulk.flush.backoff.type 重试策略,有两种:EXPONENTIAL 指数型(表示多次重试之间的时间间隔按照指数方式进行增长)、CONSTANT 常数型(表示多次重试之间的时间间隔为固定常数)

3. bulk.flush.backoff.delay 进行重试的时间间隔

4. bulk.flush.backoff.retries 失败重试的次数

5. bulk.flush.max.actions: 批量写入时的最大写入条数

6. bulk.flush.max.size.mb: 批量写入时的最大数据量

7. bulk.flush.interval.ms: 批量写入的时间间隔,配置后则会按照该时间间隔严格执行,无视上面的两个批量写入配置

看下,就是如下这些配置了,如果你需要的话,可以在这个地方配置扩充。

undefined

写入 ES 的时候会有这些情况会导致写入 ES 失败。

1、ES 集群队列满了,报如下错误:

1
12:08:07.326 [I/O dispatcher 13] ERROR o.a.f.s.c.e.ElasticsearchSinkBase - Failed Elasticsearch item request: ElasticsearchException[Elasticsearch exception [type=es_rejected_execution_exception, reason=rejected execution of org.elasticsearch.transport.TransportService$7@566c9379 on EsThreadPoolExecutor[name = node-1/write, queue capacity = 200, org.elasticsearch.common.util.concurrent.EsThreadPoolExecutor@f00b373[Running, pool size = 4, active threads = 4, queued tasks = 200, completed tasks = 6277]]]]

是这样的,我电脑安装的 ES 队列容量默认应该是 200,我没有修改过。我这里如果配置的 bulk flush size * 并发 Sink 数量 这个值如果大于这个 queue capacity ,那么就很容易导致出现这种因为 ES 队列满了而写入失败。

当然这里你也可以通过调大点 es 的队列。参考:https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-threadpool.html

2、ES 集群某个节点挂了

这个就不用说了,肯定写入失败的。跟过源码可以发现 RestClient 类里的 performRequestAsync 方法一开始会随机的从集群中的某个节点进行写入数据,如果这台机器掉线,会进行重试在其他的机器上写入,那么当时写入的这台机器的请求就需要进行失败重试,否则就会把数据丢失!

undefined

3、ES 集群某个节点的磁盘满了

这里说的磁盘满了,并不是磁盘真的就没有一点剩余空间的,是 ES 会在写入的时候检查磁盘的使用情况,在 85% 的时候会打印日志警告。

undefined

这里我看了下源码如下图:

undefined

undefined

如果你想继续让 ES 写入的话就需要去重新配一下 ES 让它继续写入,或者你也可以清空些不必要的数据腾出磁盘空间来。

解决方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
DataStream<String> input = ...;

input.addSink(new ElasticsearchSink<>(
config, transportAddresses,
new ElasticsearchSinkFunction<String>() {...},
new ActionRequestFailureHandler() {
@Override
void onFailure(ActionRequest action,
Throwable failure,
int restStatusCode,
RequestIndexer indexer) throw Throwable {

if (ExceptionUtils.containsThrowable(failure, EsRejectedExecutionException.class)) {
//队列满了,重新添加用于索引的 document
indexer.add(action);
} else if (ExceptionUtils.containsThrowable(failure, ElasticsearchParseException.class)) {
// 对于有问题的 document,删除该请求,没有额外的错误处理逻辑
} else {
//对于抛出其他的异常错误,直接就当成 sink 失败,向外抛出异常,你也可以抛出自定义的异常
throw failure;
}
}
}));

如果仅仅只是想做失败重试,也可以直接使用官方提供的默认的 RetryRejectedExecutionFailureHandler ,该处理器会对 EsRejectedExecutionException 导致到失败写入做重试处理。如果你没有设置失败处理器(failure handler),那么就会使用默认的 NoOpFailureHandler 来简单处理所有的异常。

小结与反思

本节讲了 Flink 中的 ElasticSearch Connector 的使用,通过一个案例教大家如何将读取到的 Kafka 数据写入到 ElasticSearch,最后讲解了 Flink 写入 ElasticSearch 的时候的各种配置和可能遇到的问题及其解决方法。

本节涉及的代码地址:https://github.com/zhisheng17/flink-learning/tree/master/flink-learning-connectors/flink-learning-connectors-es6

准备环境和依赖

HBase 安装

如果是苹果系统,可以使用 HomeBrew 命令安装:

1
brew install hbase

HBase 最终会安装在路径 /usr/local/Cellar/hbase/ 下面,安装版本不同,文件名也不同。

配置 HBase

打开 libexec/conf/hbase-env.sh 修改里面的 JAVA_HOME:

1
2
# The java implementation to use.  Java 1.7+ required.
export JAVA_HOME="/Library/Java/JavaVirtualMachines/jdk1.8.0_152.jdk/Contents/Home"

根据你自己的 JAVA_HOME 来配置这个变量。

打开 libexec/conf/hbase-site.xml 配置 HBase 文件存储目录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<configuration>
<property>
<name>hbase.rootdir</name>
<!-- 配置HBase存储文件的目录 -->
<value>file:///usr/local/var/hbase</value>
</property>
<property>
<name>hbase.zookeeper.property.clientPort</name>
<value>2181</value>
</property>
<property>
<name>hbase.zookeeper.property.dataDir</name>
<!-- 配置HBase存储内建zookeeper文件的目录 -->
<value>/usr/local/var/zookeeper</value>
</property>
<property>
<name>hbase.zookeeper.dns.interface</name>
<value>lo0</value>
</property>
<property>
<name>hbase.regionserver.dns.interface</name>
<value>lo0</value>
</property>
<property>
<name>hbase.master.dns.interface</name>
<value>lo0</value>
</property>

</configuration>

运行 HBase

执行启动的命令:

1
./bin/start-hbase.sh

执行后打印出来的日志如:

1
starting master, logging to /usr/local/var/log/hbase/hbase-zhisheng-master-zhisheng.out

验证是否安装成功

使用 jps 命令:

1
2
3
4
5
zhisheng@zhisheng  /usr/local/Cellar/hbase/1.2.9/libexec  jps
91302 HMaster
62535 RemoteMavenServer
1100
91471 Jps

出现 HMaster 说明安装运行成功。

启动 HBase Shell

执行下面命令:

1
./bin/hbase shell

undefined

停止 HBase

执行下面的命令:

1
./bin/stop-hbase.sh

undefined

HBase 常用命令

HBase 中常用的命令有:list(列出已存在的表)、create(创建表)、put(写数据)、get(读数据)、scan(读数据,读全表)、describe(显示表详情)

undefined

undefined

添加依赖

在 pom.xml 中添加 HBase 相关的依赖:

1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-hbase_${scala.binary.version}</artifactId>
<version>${flink.version}</version>
</dependency>
<dependency>
<groupId>org.apache.hadoop</groupId>
<artifactId>hadoop-common</artifactId>
<version>2.7.4</version>
</dependency>

Flink HBase Connector 中,HBase 不仅可以作为数据源,也还可以写入数据到 HBase 中去,我们先来看看如何从 HBase 中读取数据。

准备数据

先往 HBase 中插入五条数据如下:

1
2
3
4
5
put 'zhisheng', 'first', 'info:bar', 'hello'
put 'zhisheng', 'second', 'info:bar', 'zhisheng001'
put 'zhisheng', 'third', 'info:bar', 'zhisheng002'
put 'zhisheng', 'four', 'info:bar', 'zhisheng003'
put 'zhisheng', 'five', 'info:bar', 'zhisheng004'

scan 整个 zhisheng 表的话,有五条数据:

undefined

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/**
* Desc: 读取 HBase 数据
*/
public class HBaseReadMain {
//表名
public static final String HBASE_TABLE_NAME = "zhisheng";
// 列族
static final byte[] INFO = "info".getBytes(ConfigConstants.DEFAULT_CHARSET);
//列名
static final byte[] BAR = "bar".getBytes(ConfigConstants.DEFAULT_CHARSET);

public static void main(String[] args) throws Exception {
ExecutionEnvironment env = ExecutionEnvironment.getExecutionEnvironment();
env.createInput(new TableInputFormat<Tuple2<String, String>>() {
private Tuple2<String, String> reuse = new Tuple2<String, String>();
@Override
protected Scan getScanner() {
Scan scan = new Scan();
scan.addColumn(INFO, BAR);
return scan;
}
@Override
protected String getTableName() {
return HBASE_TABLE_NAME;
}
@Override
protected Tuple2<String, String> mapResultToTuple(Result result) {
String key = Bytes.toString(result.getRow());
String val = Bytes.toString(result.getValue(INFO, BAR));
reuse.setField(key, 0);
reuse.setField(val, 1);
return reuse;
}
}).filter(new FilterFunction<Tuple2<String, String>>() {
@Override
public boolean filter(Tuple2<String, String> value) throws Exception {
return value.f1.startsWith("zhisheng");
}
}).print();
}
}

上面代码中将 HBase 中的读取全部读取出来后然后过滤以 zhisheng 开头的 value 数据。读取结果:

undefined

可以看到输出的结果中已经将以 zhisheng 开头的四条数据都打印出来了。

添加依赖

在 pom.xml 中添加依赖:

1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>org.apache.hadoop</groupId>
<artifactId>hadoop-mapreduce-client-core</artifactId>
<version>2.6.0</version>
</dependency>
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-hadoop-compatibility_${scala.binary.version}</artifactId>
<version>${flink.version}</version>
</dependency>

要在 HBase 中提交创建 zhisheng_sink 表,并且 Column 为 info_sink (如果先运行程序的话是会报错说该表不存在的):

1
create 'zhisheng_sink', 'info_sink'

undefined

接着写 Flink Job 的代码,这里我们将 WordCount 的结果 KV 数据写入到 HBase 中去,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/**
* Desc: 写入数据到 HBase
*/
public class HBaseWriteMain {
//表名
public static final String HBASE_TABLE_NAME = "zhisheng_sink";
// 列族
static final byte[] INFO = "info_sink".getBytes(ConfigConstants.DEFAULT_CHARSET);
//列名
static final byte[] BAR = "bar_sink".getBytes(ConfigConstants.DEFAULT_CHARSET);

public static void main(String[] args) throws Exception {
ExecutionEnvironment env = ExecutionEnvironment.getExecutionEnvironment();
Job job = Job.getInstance();
job.getConfiguration().set(TableOutputFormat.OUTPUT_TABLE, HBASE_TABLE_NAME);
env.fromElements(WORDS)
.flatMap(new FlatMapFunction<String, Tuple2<String, Integer>>() {
@Override
public void flatMap(String value, Collector<Tuple2<String, Integer>> out) throws Exception {
String[] splits = value.toLowerCase().split("\\W+");
for (String split : splits) {
if (split.length() > 0) {
out.collect(new Tuple2<>(split, 1));
}
}
}
})
.groupBy(0)
.sum(1)
.map(new RichMapFunction<Tuple2<String, Integer>, Tuple2<Text, Mutation>>() {
private transient Tuple2<Text, Mutation> reuse;
@Override
public void open(Configuration parameters) throws Exception {
super.open(parameters);
reuse = new Tuple2<Text, Mutation>();
}
@Override
public Tuple2<Text, Mutation> map(Tuple2<String, Integer> value) throws Exception {
reuse.f0 = new Text(value.f0);
Put put = new Put(value.f0.getBytes(ConfigConstants.DEFAULT_CHARSET));
put.addColumn(INFO, BAR, Bytes.toBytes(value.f1.toString()));
reuse.f1 = put;
return reuse;
}
}).output(new HadoopOutputFormat<Text, Mutation>(new TableOutputFormat<Text>(), job));
env.execute("Flink Connector HBase sink Example");
}
private static final String[] WORDS = new String[]{
"To be, or not to be,--that is the question:--",
"The fair is be in that orisons"
};
}

运行该 Job 的话,然后再用 HBase shell 命令去验证数据是否插入成功了:

undefined

可以看见数据已经成功写入了 11 条,然后我们验证一下数据的条数是不是一样的呢?我们在上面的代码中将 map 和 output 算子给注释掉,然后用上 print 打印出来的话,结果如下:

1
2
3
4
5
6
7
8
9
10
11
(be,3)
(is,2)
(in,1)
(or,1)
(orisons,1)
(not,1)
(the,2)
(fair,1)
(question,1)
(that,2)
(to,2)

统计的结果刚好也是 11 条数据,说明我们的写入过程中没有丢失数据。但是运行 Job 的话你会看到日志中报了一条这样的错误:

1
java.lang.IllegalArgumentException: Can not create a Path from a null string

undefined

这个问题是因为:

1
Path partitionsPath = new Path(conf.get("mapred.output.dir"), "partitions_" + UUID.randomUUID());

当配置项 mapred.output.dir 不存在时,conf.get() 将返回 null,从而导致上述异常。那么该如何解决这个问题呢?

需要在代码中或配置文件中添加配置项 mapred.output.dir。

比如在代码里加上这行代码:

1
job.getConfiguration().set("mapred.output.dir", "/tmp");

再次运行这个 Job 你就不会发现报错了。

从上面两个程序中你可以发现两个都是批程序(从 HBase 读取批量的数据、写入批量的数据进 HBase),下面跟着笔者来演示一个流程序。

读取数据

本来是打算演示从 Kafka 读取 String 类型的数据,但是为了好演示,我这里直接在代码里面造一些数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
DataStream<String> dataStream = env.addSource(new SourceFunction<String>() {
private static final long serialVersionUID = 1L;
private volatile boolean isRunning = true;
@Override
public void run(SourceContext<String> out) throws Exception {
while (isRunning) {
out.collect(String.valueOf(Math.floor(Math.random() * 100)));
}
}
@Override
public void cancel() {
isRunning = false;
}
});

如果是读取 Kafka 数据请对应替换成:

1
2
3
4
env.addSource(new FlinkKafkaConsumer011<>(
parameterTool.get(METRICS_TOPIC), //这个 kafka topic 需要和上面的工具类的 topic 一致
new SimpleStringSchema(),
props));

写入数据

获取到数据后需要将数据写入到 HBase,这里使用的实现 HBaseOutputFormat 接口,然后重写里面的 configure、open、writeRecord、close 方法,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
private static class HBaseOutputFormat implements OutputFormat<String> {
private org.apache.hadoop.conf.Configuration configuration;
private Connection connection = null;
private String taskNumber = null;
private Table table = null;
private int rowNumber = 0;

@Override
public void configure(Configuration parameters) {
//设置配置信息
configuration = HBaseConfiguration.create();
configuration.set(HBASE_ZOOKEEPER_QUORUM, ExecutionEnvUtil.PARAMETER_TOOL.get(HBASE_ZOOKEEPER_QUORUM));
configuration.set(HBASE_ZOOKEEPER_PROPERTY_CLIENTPORT, ExecutionEnvUtil.PARAMETER_TOOL.get(HBASE_ZOOKEEPER_PROPERTY_CLIENTPORT));
configuration.set(HBASE_RPC_TIMEOUT, ExecutionEnvUtil.PARAMETER_TOOL.get(HBASE_RPC_TIMEOUT));
configuration.set(HBASE_CLIENT_OPERATION_TIMEOUT, ExecutionEnvUtil.PARAMETER_TOOL.get(HBASE_CLIENT_OPERATION_TIMEOUT));
configuration.set(HBASE_CLIENT_SCANNER_TIMEOUT_PERIOD, ExecutionEnvUtil.PARAMETER_TOOL.get(HBASE_CLIENT_SCANNER_TIMEOUT_PERIOD));
}

@Override
public void open(int taskNumber, int numTasks) throws IOException {
connection = ConnectionFactory.createConnection(configuration);
TableName tableName = TableName.valueOf(ExecutionEnvUtil.PARAMETER_TOOL.get(HBASE_TABLE_NAME));
Admin admin = connection.getAdmin();
if (!admin.tableExists(tableName)) { //检查是否有该表,如果没有,创建
log.info("==============不存在表 = {}", tableName);
admin.createTable(new HTableDescriptor(TableName.valueOf(ExecutionEnvUtil.PARAMETER_TOOL.get(HBASE_TABLE_NAME)))
.addFamily(new HColumnDescriptor(ExecutionEnvUtil.PARAMETER_TOOL.get(HBASE_COLUMN_NAME))));
}
table = connection.getTable(tableName);
this.taskNumber = String.valueOf(taskNumber);
}

@Override
public void writeRecord(String record) throws IOException {
Put put = new Put(Bytes.toBytes(taskNumber + rowNumber));
put.addColumn(Bytes.toBytes(ExecutionEnvUtil.PARAMETER_TOOL.get(HBASE_COLUMN_NAME)), Bytes.toBytes("zhisheng"),
Bytes.toBytes(String.valueOf(rowNumber)));
rowNumber++;
table.put(put);
}

@Override
public void close() throws IOException {
table.close();
connection.close();
}
}

配置文件

配置文件中的一些配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
kafka.brokers=localhost:9092
kafka.group.id=zhisheng
kafka.zookeeper.connect=localhost:2181
metrics.topic=zhisheng
stream.parallelism=4
stream.sink.parallelism=4
stream.default.parallelism=4
stream.checkpoint.interval=1000
stream.checkpoint.enable=false

# HBase
hbase.zookeeper.quorum=localhost:2181
hbase.client.retries.number=1
hbase.master.info.port=-1
hbase.zookeeper.property.clientPort=2081
hbase.rpc.timeout=30000
hbase.client.operation.timeout=30000
hbase.client.scanner.timeout.period=30000

# HBase table name
hbase.table.name=zhisheng_stream
hbase.column.name=info_stream

项目运行及验证

运行项目后然后你再去用 HBase shell 命令查看你会发现该 zhisheng_stream 表之前没有建立,现在建立了,再通过 scan 命令查看的话,你会发现数据一直在更新,不断增加数据条数。

undefined

小结与反思

本节开始讲解了 HBase 相关的环境安装和基础命令,接着讲解了如何去读取 HBase 数据和写入数据到 HBase。

本节涉及的完整代码地址在:https://github.com/zhisheng17/flink-learning/tree/master/flink-learning-connectors/flink-learning-connectors-hbase

在生产环境中,通常会将一些计算后的数据存储在 Redis 中,以供第三方的应用去 Redis 查找对应的数据,至于 Redis 的特性笔者不会在本节做过多的讲解。

安装 Redis

下载安装

先在 https://redis.io/download 下载到 Redis。

1
2
3
4
wget http://download.redis.io/releases/redis-5.0.4.tar.gz
tar xzf redis-5.0.4.tar.gz
cd redis-5.0.4
make

通过 HomeBrew 安装

1
brew install redis

如果需要后台运行 Redis 服务,使用命令:

1
brew services start redis

要运行命令,可以直接到 /usr/local/bin 目录下,有:

1
2
redis-server
redis-cli

两个命令,执行 redis-server 可以打开服务端:

undefined

然后另外开一个终端,运行 redis-cli 命令可以运行客户端:

undefined

准备商品数据发送至 Kafka

这里我打算将从 Kafka 读取到所有到商品的信息,然后将商品信息中的 商品ID商品价格 提取出来,然后写入到 Redis 中,供第三方服务根据商品 ID 查询到其对应的商品价格。

首先定义我们的商品类 (其中 id 和 price 字段是我们最后要提取的)为:

ProductEvent.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
/**
* Desc: 商品
* blog:http://www.54tianzhisheng.cn/
* 微信公众号:zhisheng
*/
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class ProductEvent {

/**
* Product Id
*/
private Long id;

/**
* Product 类目 Id
*/
private Long categoryId;

/**
* Product 编码
*/
private String code;

/**
* Product 店铺 Id
*/
private Long shopId;

/**
* Product 店铺 name
*/
private String shopName;

/**
* Product 品牌 Id
*/
private Long brandId;

/**
* Product 品牌 name
*/
private String brandName;

/**
* Product name
*/
private String name;

/**
* Product 图片地址
*/
private String imageUrl;

/**
* Product 状态(1(上架),-1(下架),-2(冻结),-3(删除))
*/
private int status;

/**
* Product 类型
*/
private int type;

/**
* Product 标签
*/
private List<String> tags;

/**
* Product 价格(以分为单位)
*/
private Long price;
}

然后写个工具类不断的模拟商品数据发往 Kafka,工具类 ProductUtil.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class ProductUtil {
public static final String broker_list = "localhost:9092";
public static final String topic = "zhisheng"; //kafka topic 需要和 flink 程序用同一个 topic

public static final Random random = new Random();

public static void main(String[] args) {
Properties props = new Properties();
props.put("bootstrap.servers", broker_list);
props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
KafkaProducer producer = new KafkaProducer<String, String>(props);

for (int i = 1; i <= 10000; i++) {
ProductEvent product = ProductEvent.builder().id((long) i) //商品的 id
.name("product" + i) //商品 name
.price(random.nextLong() / 10000000000000L) //商品价格(以分为单位)
.code("code" + i).build(); //商品编码

ProducerRecord record = new ProducerRecord<String, String>(topic, null, null, GsonUtil.toJson(product));
producer.send(record);
System.out.println("发送数据: " + GsonUtil.toJson(product));
}
producer.flush();
}
}

我们需要在 Flink 中消费 Kafka 数据,然后将商品中的两个数据(商品 id 和 price)取出来。先来看下这段 Flink Job 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Main {
public static void main(String[] args) throws Exception {
final StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
ParameterTool parameterTool = ExecutionEnvUtil.PARAMETER_TOOL;
Properties props = KafkaConfigUtil.buildKafkaProps(parameterTool);

SingleOutputStreamOperator<Tuple2<String, String>> product = env.addSource(new FlinkKafkaConsumer011<>(
parameterTool.get(METRICS_TOPIC), //这个 kafka topic 需要和上面的工具类的 topic 一致
new SimpleStringSchema(),
props))
.map(string -> GsonUtil.fromJson(string, ProductEvent.class)) //反序列化 JSON
.flatMap(new FlatMapFunction<ProductEvent, Tuple2<String, String>>() {
@Override
public void flatMap(ProductEvent value, Collector<Tuple2<String, String>> out) throws Exception {
//收集商品 id 和 price 两个属性
out.collect(new Tuple2<>(value.getId().toString(), value.getPrice().toString()));
}
});
product.print();

env.execute("flink redis connector");
}
}

然后 IDEA 中启动运行 Job,再运行上面的 ProductUtil 发送 Kafka 数据的工具类,注意:也得提前启动 Kafka。

undefined

上图左半部分是工具类发送数据到 Kafka 打印的日志,右半部分是 Job 执行的结果,可以看到它已经将商品的 id 和 price 数据获取到了。

那么接下来我们需要的就是将这种 Tuple2 格式的 KV 数据写入到 Redis 中去。要将数据写入到 Redis 的话是需要先添加依赖的。

Redis Connector 简介

Redis Connector 提供用于向 Redis 发送数据的接口的类。接收器可以使用三种不同的方法与不同类型的 Redis 环境进行通信:

  • 单 Redis 服务器
  • Redis 集群
  • Redis Sentinel

添加依赖

需要添加 Flink Redis Sink 的 Connector,这个 Redis Connector 官方只有老的版本,后面也一直没有更新,所以可以看到网上有些文章都是添加老的版本的依赖:

1
2
3
4
5
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-connector-redis_2.10</artifactId>
<version>1.1.5</version>
</dependency>

包括该部分的文档都是很早之前的啦,可以查看 https://ci.apache.org/projects/flink/flink-docs-release-1.1/apis/streaming/connectors/redis.html。

另外在 https://bahir.apache.org/docs/flink/current/flink-streaming-redis/ 也看到一个 Flink Redis Connector 的依赖:

1
2
3
4
5
<dependency>
<groupId>org.apache.bahir</groupId>
<artifactId>flink-connector-redis_2.11</artifactId>
<version>1.0</version>
</dependency>

两个依赖功能都是一样的,我们还是就用官方的那个 Maven 依赖来进行演示。

像写入到 Redis,那么肯定要配置 Redis 服务的地址(不管是单机的还是集群)。

单机的 Redis 你可以这样配置:

1
FlinkJedisPoolConfig conf = new FlinkJedisPoolConfig.Builder().setHost("127.0.0.1").build();

这个 FlinkJedisPoolConfig 源码中有四个属性:

1
2
3
4
private final String host;  //hostname or IP
private final int port; //端口,默认 6379
private final int database; //database index
private final String password; //password

另外你还可以通过 FlinkJedisPoolConfig 设置其他的的几个属性(因为 FlinkJedisPoolConfig 继承自 FlinkJedisConfigBase,这几个属性在 FlinkJedisConfigBase 抽象类的):

1
2
3
4
protected final int maxTotal;   //池可分配的对象最大数量,默认是 8
protected final int maxIdle; //池中空闲的对象最大数量,默认是 8
protected final int minIdle; //池中空闲的对象最小数量,默认是 0
protected final int connectionTimeout; //socket 或者连接超时时间,默认是 2000ms

Redis 集群 你可以这样配置:

1
2
3
FlinkJedisClusterConfig config = new FlinkJedisClusterConfig.Builder()
.setNodes(new HashSet<InetSocketAddress>(
Arrays.asList(new InetSocketAddress("redis1", 6379)))).build();

Redis Sentinels 你可以这样配置:

1
2
3
4
5
FlinkJedisSentinelConfig sentinelConfig = new FlinkJedisSentinelConfig.Builder()
.setMasterName("master")
.setSentinels(new HashSet<>(Arrays.asList("sentinel1", "sentinel2")))
.setPassword("")
.setDatabase(1).build();

另外就是 Redis Sink 了,Redis Sink 核心类是 RedisMapper,它是一个接口,里面有三个方法,使用时我们需要重写这三个方法:

1
2
3
4
5
6
7
8
public interface RedisMapper<T> extends Function, Serializable {
//设置使用 Redis 的数据结构类型,和 key 的名词,RedisCommandDescription 中有两个属性 RedisCommand、key
RedisCommandDescription getCommandDescription();
//获取 key 值
String getKeyFromData(T var1);
//获取 value 值
String getValueFromData(T var1);
}

上面 RedisCommandDescription 中有两个属性 RedisCommand、key。RedisCommand 可以设置 Redis 的数据结果类型,下面是 Redis 数据结构的类型对应着的 Redis Command 的类型:

undefined

其对应的源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public enum RedisCommand {
LPUSH(RedisDataType.LIST),
RPUSH(RedisDataType.LIST),
SADD(RedisDataType.SET),
SET(RedisDataType.STRING),
PFADD(RedisDataType.HYPER_LOG_LOG),
PUBLISH(RedisDataType.PUBSUB),
ZADD(RedisDataType.SORTED_SET),
HSET(RedisDataType.HASH);

private RedisDataType redisDataType;

private RedisCommand(RedisDataType redisDataType) {
this.redisDataType = redisDataType;
}

public RedisDataType getRedisDataType() {
return this.redisDataType;
}
}

我们实现这个 RedisMapper 接口如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static class RedisSinkMapper implements RedisMapper<Tuple2<String, String>> {
@Override
public RedisCommandDescription getCommandDescription() {
//指定 RedisCommand 的类型是 HSET,对应 Redis 中的数据结构是 HASH,另外设置 key = zhisheng
return new RedisCommandDescription(RedisCommand.HSET, "zhisheng");
}

@Override
public String getKeyFromData(Tuple2<String, String> data) {
return data.f0;
}

@Override
public String getValueFromData(Tuple2<String, String> data) {
return data.f1;
}
}

然后在 Flink Job 中加入下面这行,将数据通过 RedisSinkMapper 写入到 Redis 中去:

1
product.addSink(new RedisSink<Tuple2<String, String>>(conf, new RedisSinkMapper()));

验证写入结果

运行 Job 的话,就是把数据已经插入进 Redis 了,那么如何验证我们的结果是否正确呢?

1、我们去终端 Cli 执行命令查看这个 zhisheng 的 key,然后查找某个商品 id (1 ~ 10000) 对应的商品价格,超过这个 id 则为 nil。

undefined

2、另外一种验证的方式就是通过 Java 代码来操作 Redis 查询数据了。

我们先引入 Redis 的依赖:

1
2
3
4
5
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>2.9.0</version>
</dependency>

连接 Redis 查询数据:

1
2
3
4
5
6
7
public class RedisTest {
public static void main(String[] args) {
Jedis jedis = new Jedis("127.0.0.1");
System.out.println("Server is running: " + jedis.ping());
System.out.println("result:" + jedis.hgetAll("zhisheng"));
}
}

undefined

这一行把所有的数据都打印出来了,所以我们的数据确实成功地插入到 Redis 中去了。

小结与反思

本文先讲解了 Redis 的安装,然后讲了 Flink 如何消费 Kafka 的数据并将数据写入到 Redis 中去。在实战的过程中还分析了 Flink Redis Connector 中的原理,只要我们懂得了这些原理,后面再去做这块的需求就难不倒大家了。

本节涉及的代码地址在:https://github.com/zhisheng17/flink-learning/tree/master/flink-learning-connectors/flink-learning-connectors-redis

十九、如何使用 Side Output 来分流?

通常,在 Kafka 的 topic 中会有很多数据,这些数据虽然结构是一致的,但是类型可能不一致,举个例子:Kafka 中的监控数据有很多种:机器、容器、应用、中间件等,如果要对这些数据分别处理,就需要对这些数据流进行一个拆分,那么在 Flink 中该怎么完成这需求呢,有如下这些方法。

使用 Filter 分流

使用 filter 算子根据数据的字段进行过滤分成机器、容器、应用、中间件等。伪代码如下:

1
2
3
4
5
DataStreamSource<MetricEvent> data = KafkaConfigUtil.buildSource(env);  //从 Kafka 获取到所有的数据流
SingleOutputStreamOperator<MetricEvent> machineData = data.filter(m -> "machine".equals(m.getTags().get("type"))); //过滤出机器的数据
SingleOutputStreamOperator<MetricEvent> dockerData = data.filter(m -> "docker".equals(m.getTags().get("type"))); //过滤出容器的数据
SingleOutputStreamOperator<MetricEvent> applicationData = data.filter(m -> "application".equals(m.getTags().get("type"))); //过滤出应用的数据
SingleOutputStreamOperator<MetricEvent> middlewareData = data.filter(m -> "middleware".equals(m.getTags().get("type"))); //过滤出中间件的数据

使用 Split 分流

先在 split 算子里面定义 OutputSelector 的匿名内部构造类,然后重写 select 方法,根据数据的类型将不同的数据放到不同的 tag 里面,这样返回后的数据格式是 SplitStream,然后要使用这些数据的时候,可以通过 select 去选择对应的数据类型,伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
DataStreamSource<MetricEvent> data = KafkaConfigUtil.buildSource(env);  //从 Kafka 获取到所有的数据流
SplitStream<MetricEvent> splitData = data.split(new OutputSelector<MetricEvent>() {
@Override
public Iterable<String> select(MetricEvent metricEvent) {
List<String> tags = new ArrayList<>();
String type = metricEvent.getTags().get("type");
switch (type) {
case "machine":
tags.add("machine");
break;
case "docker":
tags.add("docker");
break;
case "application":
tags.add("application");
break;
case "middleware":
tags.add("middleware");
break;
default:
break;
}
return tags;
}
});

DataStream<MetricEvent> machine = splitData.select("machine");
DataStream<MetricEvent> docker = splitData.select("docker");
DataStream<MetricEvent> application = splitData.select("application");
DataStream<MetricEvent> middleware = splitData.select("middleware");

上面这种只分流一次是没有问题的,注意如果要使用它来做连续的分流,那是有问题的,笔者曾经就遇到过这个问题,当时记录了博客 —— Flink 从0到1学习—— Flink 不可以连续 Split(分流)? ,当时排查这个问题还查到两个相关的 Flink Issue。

这两个 Issue 反映的就是连续 split 不起作用,在第二个 Issue 下面的评论就有回复说 Side Output 的功能比 split 更强大, split 会在后面的版本移除(其实在 1.7.x 版本就已经设置为过期),那么下面就来学习一下 Side Output。

使用 Side Output 分流

要使用 Side Output 的话,你首先需要做的是定义一个 OutputTag 来标识 Side Output,代表这个 Tag 是要收集哪种类型的数据,如果是要收集多种不一样类型的数据,那么你就需要定义多种 OutputTag。要完成本节前面的需求,需要定义 4 个 OutputTag,如下:

1
2
3
4
5
6
7
8
9
//创建 output tag
private static final OutputTag<MetricEvent> machineTag = new OutputTag<MetricEvent>("machine") {
};
private static final OutputTag<MetricEvent> dockerTag = new OutputTag<MetricEvent>("docker") {
};
private static final OutputTag<MetricEvent> applicationTag = new OutputTag<MetricEvent>("application") {
};
private static final OutputTag<MetricEvent> middlewareTag = new OutputTag<MetricEvent>("middleware") {
};

定义好 OutputTag 后,可以使用下面几种函数来处理数据:

  • ProcessFunction
  • KeyedProcessFunction
  • CoProcessFunction
  • ProcessWindowFunction
  • ProcessAllWindowFunction

在利用上面的函数处理数据的过程中,需要对数据进行判断,将不同种类型的数据存到不同的 OutputTag 中去,如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
DataStreamSource<MetricEvent> data = KafkaConfigUtil.buildSource(env);  //从 Kafka 获取到所有的数据流
SingleOutputStreamOperator<MetricEvent> sideOutputData = data.process(new ProcessFunction<MetricEvent, MetricEvent>() {
@Override
public void processElement(MetricEvent metricEvent, Context context, Collector<MetricEvent> collector) throws Exception {
String type = metricEvent.getTags().get("type");
switch (type) {
case "machine":
context.output(machineTag, metricEvent);
case "docker":
context.output(dockerTag, metricEvent);
case "application":
context.output(applicationTag, metricEvent);
case "middleware":
context.output(middlewareTag, metricEvent);
default:
collector.collect(metricEvent);
}
}
});

好了,既然上面已经将不同类型的数据放到不同的 OutputTag 里面了,那么该如何去获取呢?可以使用 getSideOutput 方法来获取不同 OutputTag 的数据,比如:

1
2
3
4
DataStream<MetricEvent> machine = sideOutputData.getSideOutput(machineTag);
DataStream<MetricEvent> docker = sideOutputData.getSideOutput(dockerTag);
DataStream<MetricEvent> application = sideOutputData.getSideOutput(applicationTag);
DataStream<MetricEvent> middleware = sideOutputData.getSideOutput(middlewareTag);

这样你就可以获取到 Side Output 数据了,其实在 3.4 和 3.5 节就讲了 Side Output 在 Flink 中的应用(处理窗口的延迟数据),大家如果没有印象了可以再返回去复习一下。

小结与反思

本节讲了下 Flink 中将数据分流的三种方式,涉及的完整代码 GitHub 地址:https://github.com/zhisheng17/flink-learning/tree/master/flink-learning-examples/src/main/java/com/zhisheng/examples/streaming/sideoutput

在基础篇中的 1.2 节中介绍了 Flink 是一款有状态的流处理框架。那么大家可能有点疑问,这个状态是什么意思?拿 Flink 最简单的 Word Count 程序来说,它需要不断的对 word 出现的个数进行结果统计,那么后一个结果就需要利用前一个的结果然后再做 +1 的操作,这样前一个计算就需要将 word 出现的次数 count 进行存着(这个 count 那么就是一个状态)然后后面才可以进行累加。

为什么需要 state?

对于流处理系统,数据是一条一条被处理的,如果没有对数据处理的进度进行记录,那么如果这个处理数据的 Job 因为机器问题或者其他问题而导致重启,那么它是不知道上一次处理数据是到哪个地方了,这样的情况下如果是批数据,倒是可以很好的解决(重新将这份固定的数据再执行一遍),但是流数据那就麻烦了,你根本不知道什么在 Job 挂的那个时刻数据消费到哪里了?那么你重启的话该从哪里开始重新消费呢?你可以有以下选择(因为你可能也不确定 Job 挂的具体时间):

  • Job 挂的那个时间之前:如果是从 Job 挂之前开始重新消费的话,那么会导致部分数据(从新消费的时间点到之前 Job 挂的那个时间点之前的数据)重复消费
  • Job 挂的那个时间之后:如果是从 Job 挂之后开始消费的话,那么会导致部分数据(从 Job 挂的那个时间点到新消费的时间点产生的数据)丢失,没有消费

undefined

为了解决上面两种情况(数据重复消费或者数据没有消费)的发生,那么是不是就得需要个什么东西做个记录将这种数据消费状态,Flink state 就这样诞生了,state 中存储着每条数据消费后数据的消费点(生产环境需要持久化这些状态),当 Job 因为某种错误或者其他原因导致重启时,就能够从 checkpoint(定时将 state 做一个全局快照,在 Flink 中,为了能够让 Job 在运行的过程中保证容错性,才会对这些 state 做一个快照,在 4.3 节中会详细讲) 中的 state 数据进行恢复。

State 的种类

在 Flink 中有两个基本的 state:Keyed state 和 Operator state,下面来分别介绍一下这两种 State。

Keyed State

Keyed State 总是和具体的 key 相关联,也只能在 KeyedStream 的 function 和 operator 上使用。你可以将 Keyed State 当作是 Operator State 的一种特例,但是它是被分区或分片的。每个 Keyed State 分区对应一个 key 的 Operator State,对于某个 key 在某个分区上有唯一的状态。逻辑上,Keyed State 总是对应着一个 二元组,在某种程度上,因为每个具体的 key 总是属于唯一一个具体的 parallel-operator-instance(并行操作实例),这种情况下,那么就可以简化认为是 。Keyed State 可以进一步组织成 Key Group,Key Group 是 Flink 重新分配 Keyed State 的最小单元,所以有多少个并行,就会有多少个 Key Group。在执行过程中,每个 keyed operator 的并行实例会处理来自不同 key 的不同 Key Group。

Operator State

对 Operator State 而言,每个 operator state 都对应着一个并行实例。Kafka Connector 就是一个很好的例子。每个 Kafka consumer 的并行实例都会持有一份topic partition 和 offset 的 map,这个 map 就是它的 Operator State。

当并行度发生变化时,Operator State 可以将状态在所有的并行实例中进行重分配,并且提供了多种方式来进行重分配。

在 Flink 源码中,在 flink-core module 下的 org.apache.flink.api.common.state 中可以看到 Flink 中所有和 State 相关的类。

undefined

Raw and Managed State

Keyed State 和 Operator State 都有两种存在形式,即 Raw State(原始状态)和 Managed State(托管状态)。

原始状态是 Operator(算子)保存它们自己的数据结构中的 state,当 checkpoint 时,原始状态会以字节流的形式写入进 checkpoint 中。Flink 并不知道 State 的数据结构长啥样,仅能看到原生的字节数组。

托管状态可以使用 Flink runtime 提供的数据结构来表示,例如内部哈希表或者 RocksDB。具体有 ValueState,ListState 等。Flink runtime 会对这些状态进行编码然后将它们写入到 checkpoint 中。

DataStream 的所有 function 都可以使用托管状态,但是原生状态只能在实现 operator 的时候使用。相对于原生状态,推荐使用托管状态,因为如果使用托管状态,当并行度发生改变时,Flink 可以自动的帮你重分配 state,同时还可以更好的管理内存。

注意:如果你的托管状态需要特殊的序列化,目前 Flink 还不支持。

如何使用托管 Keyed State

托管的 Keyed State 接口提供对不同类型状态(这些状态的范围都是当前输入元素的 key)的访问,这意味着这种状态只能在通过 stream.keyBy() 创建的 KeyedStream 上使用。

我们首先来看一下有哪些可以使用的状态,然后再来看看它们在程序中是如何使用的:

  • ValueState: 保存一个可以更新和获取的值(每个 Key 一个 value),可以用 update(T) 来更新 value,可以用 value() 来获取 value。
  • ListState: 保存一个值的列表,用 add(T) 或者 addAll(List) 来添加,用 Iterable get() 来获取。
  • ReducingState: 保存一个值,这个值是状态的很多值的聚合结果,接口和 ListState 类似,但是可以用相应的 ReduceFunction 来聚合。
  • AggregatingState: 保存很多值的聚合结果的单一值,与 ReducingState 相比,不同点在于聚合类型可以和元素类型不同,提供 AggregateFunction 来实现聚合。
  • FoldingState: 与 AggregatingState 类似,除了使用 FoldFunction 进行聚合。
  • MapState: 保存一组映射,可以将 kv 放进这个状态,使用 put(UK, UV) 或者 putAll(Map) 添加,或者使用 get(UK) 获取。

所有类型的状态都有一个 clear() 方法来清除当前的状态。

注意:FoldingState 已经不推荐使用,可以用 AggregatingState 来代替。

需要注意,上面的这些状态对象仅用来和状态打交道,状态不一定保存在内存中,也可以存储在磁盘或者其他地方。另外,你获取到的状态的值是取决于输入元素的 key,因此如果 key 不同,那么在一次调用用户函数中获得的值可能与另一次调用的值不同。

要使用一个状态对象,需要先创建一个 StateDescriptor,它包含了状态的名字(你可以创建若干个 state,但是它们必须要有唯一的值以便能够引用它们),状态的值的类型,或许还有一个用户定义的函数,比如 ReduceFunction。根据你想要使用的 state 类型,你可以创建 ValueStateDescriptor、ListStateDescriptor、ReducingStateDescriptor、FoldingStateDescriptor 或者 MapStateDescriptor。

状态只能通过 RuntimeContext 来获取,所以只能在 RichFunction 里面使用。RichFunction 中你可以通过 RuntimeContext 用下述方法获取状态:

  • ValueState getState(ValueStateDescriptor)
  • ReducingState getReducingState(ReducingStateDescriptor)
  • ListState getListState(ListStateDescriptor)
  • AggregatingState getAggregatingState(AggregatingState)
  • FoldingState getFoldingState(FoldingStateDescriptor)
  • MapState getMapState(MapStateDescriptor)

上面讲了这么多概念,那么来一个例子来看看如何使用状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public class CountWindowAverage extends RichFlatMapFunction<Tuple2<Long, Long>, Tuple2<Long, Long>> {

//ValueState 使用方式,第一个字段是 count,第二个字段是运行的和
private transient ValueState<Tuple2<Long, Long>> sum;

@Override
public void flatMap(Tuple2<Long, Long> input, Collector<Tuple2<Long, Long>> out) throws Exception {

//访问状态的 value 值
Tuple2<Long, Long> currentSum = sum.value();

//更新 count
currentSum.f0 += 1;

//更新 sum
currentSum.f1 += input.f1;

//更新状态
sum.update(currentSum);

//如果 count 等于 2, 发出平均值并清除状态
if (currentSum.f0 >= 2) {
out.collect(new Tuple2<>(input.f0, currentSum.f1 / currentSum.f0));
sum.clear();
}
}

@Override
public void open(Configuration config) {
ValueStateDescriptor<Tuple2<Long, Long>> descriptor =
new ValueStateDescriptor<>(
"average", //状态名称
TypeInformation.of(new TypeHint<Tuple2<Long, Long>>() {}), //类型信息
Tuple2.of(0L, 0L)); //状态的默认值
sum = getRuntimeContext().getState(descriptor);//获取状态
}
}

env.fromElements(Tuple2.of(1L, 3L), Tuple2.of(1L, 5L), Tuple2.of(1L, 7L), Tuple2.of(1L, 4L), Tuple2.of(1L, 2L))
.keyBy(0)
.flatMap(new CountWindowAverage())
.print();

//结果会打印出 (1,4) 和 (1,5)

这个例子实现了一个简单的计数器,我们使用元组的第一个字段来进行分组(这个例子中,所有的 key 都是 1),这个 CountWindowAverage 函数将计数和运行时总和保存在一个 ValueState 中,一旦计数等于 2,就会发出平均值并清理 state,因此又从 0 开始。请注意,如果在第一个字段中具有不同值的元组,则这将为每个不同的输入 key保存不同的 state 值。

State TTL(存活时间)

State TTL 介绍

TTL 可以分配给任何类型的 Keyed state,如果一个状态设置了 TTL,那么当状态过期时,那么之前存储的状态值会被清除。所有的状态集合类型都支持单个入口的 TTL,这意味着 List 集合元素和 Map 集合都支持独立到期。为了使用状态 TTL,首先必须要构建 StateTtlConfig 配置对象,然后可以通过传递配置在 State descriptor 中启用 TTL 功能:

1
2
3
4
5
6
7
8
9
10
11
12
import org.apache.flink.api.common.state.StateTtlConfig;
import org.apache.flink.api.common.state.ValueStateDescriptor;
import org.apache.flink.api.common.time.Time;

StateTtlConfig ttlConfig = StateTtlConfig
.newBuilder(Time.seconds(1))
.setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite)
.setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired)
.build();

ValueStateDescriptor<String> stateDescriptor = new ValueStateDescriptor<>("zhisheng", String.class);
stateDescriptor.enableTimeToLive(ttlConfig); //开启 ttl

上面配置中有几个选项需要注意:

1、newBuilder 方法的第一个参数是必需的,它代表着状态存活时间。

2、UpdateType 配置状态 TTL 更新时(默认为 OnCreateAndWrite):

  • StateTtlConfig.UpdateType.OnCreateAndWrite: 仅限创建和写入访问时更新
  • StateTtlConfig.UpdateType.OnReadAndWrite: 除了创建和写入访问,还支持在读取时更新

3、StateVisibility 配置是否在读取访问时返回过期值(如果尚未清除),默认是 NeverReturnExpired:

  • StateTtlConfig.StateVisibility.NeverReturnExpired: 永远不会返回过期值
  • StateTtlConfig.StateVisibility.ReturnExpiredIfNotCleanedUp: 如果仍然可用则返回

在 NeverReturnExpired 的情况下,过期状态表现得好像它不再存在,即使它仍然必须被删除。该选项对于在 TTL 之后必须严格用于读取访问的数据的用例是有用的,例如,应用程序使用隐私敏感数据.

另一个选项 ReturnExpiredIfNotCleanedUp 允许在清理之前返回过期状态。

注意:

  • 状态后端会存储上次修改的时间戳以及对应的值,这意味着启用此功能会增加状态存储的消耗,堆状态后端存储一个额外的 Java 对象,其中包含对用户状态对象的引用和内存中原始的 long 值。RocksDB 状态后端存储为每个存储值、List、Map 都添加 8 个字节。
  • 目前仅支持参考 processing time 的 TTL
  • 使用启用 TTL 的描述符去尝试恢复先前未使用 TTL 配置的状态可能会导致兼容性失败或者 StateMigrationException 异常。
  • TTL 配置并不是 Checkpoint 和 Savepoint 的一部分,而是 Flink 如何在当前运行的 Job 中处理它的方式。
  • 只有当用户值序列化器可以处理 null 值时,具体 TTL 的 Map 状态当前才支持 null 值,如果序列化器不支持 null 值,则可以使用 NullableSerializer 来包装它(代价是需要一个额外的字节)。

清除过期 state

默认情况下,过期值只有在显式读出时才会被删除,例如通过调用 ValueState.value()。

注意:这意味着默认情况下,如果未读取过期状态,则不会删除它,这可能导致状态不断增长,这个特性在 Flink 未来的版本可能会发生变化。

此外,你可以在获取完整状态快照时激活清理状态,这样就可以减少状态的大小。在当前实现下不清除本地状态,但是在从上一个快照恢复的情况下,它不会包括已删除的过期状态,你可以在 StateTtlConfig 中这样配置:

1
2
3
4
5
6
7
import org.apache.flink.api.common.state.StateTtlConfig;
import org.apache.flink.api.common.time.Time;

StateTtlConfig ttlConfig = StateTtlConfig
.newBuilder(Time.seconds(1))
.cleanupFullSnapshot()
.build();

此配置不适用于 RocksDB 状态后端中的增量 checkpoint。对于现有的 Job,可以在 StateTtlConfig 中随时激活或停用此清理策略,例如,从保存点重启后。

除了在完整快照中清理外,你还可以在后台激活清理。如果使用的后端支持以下选项,则会激活 StateTtlConfig 中的默认后台清理:

1
2
3
4
5
import org.apache.flink.api.common.state.StateTtlConfig;
StateTtlConfig ttlConfig = StateTtlConfig
.newBuilder(Time.seconds(1))
.cleanupInBackground()
.build();

要在后台对某些特殊清理进行更精细的控制,可以按照下面的说明单独配置它。目前,堆状态后端依赖于增量清理,RocksDB 后端使用压缩过滤器进行后台清理。

我们再来看看 TTL 对应着的类 StateTtlConfig 类中的具体实现,这样我们才能更加的理解其使用方式。

在该类中的属性有如下:

undefined

  • DISABLED:它默认创建了一个 UpdateType 为 Disabled 的 StateTtlConfig
  • UpdateType:这个是一个枚举,包含 Disabled(代表 TTL 是禁用的,状态不会过期)、OnCreateAndWrite、OnReadAndWrite 可选
  • StateVisibility:这也是一个枚举,包含了 ReturnExpiredIfNotCleanedUp、NeverReturnExpired
  • TimeCharacteristic:这是时间特征,其实是只有 ProcessingTime 可选
  • Time:设置 TTL 的时间,这里有两个参数 unit 和 size
  • CleanupStrategies:TTL 清理策略,在该类中又有字段 isCleanupInBackground(是否在后台清理) 和相关的清理 strategies(包含 FULLSTATESCANSNAPSHOT、INCREMENTALCLEANUP 和 ROCKSDBCOMPACTIONFILTER),同时该类中还有 CleanupStrategy 接口,它的实现类有 EmptyCleanupStrategy(不清理,为空)、IncrementalCleanupStrategy(增量的清除)、RocksdbCompactFilterCleanupStrategy(在 RocksDB 中自定义压缩过滤器)。

undefined

如果对 State TTL 还有不清楚的可以看看 Flink 源码 flink-runtime module 中的 state ttl 相关的实现:

undefined

如何使用托管 Operator State

为了使用托管的 Operator State,必须要有一个有状态的函数,这个函数可以实现 CheckpointedFunction 或者 ListCheckpointed 接口。

下面分别讲一下如何使用:

CheckpointedFunction

如果是实现 CheckpointedFunction 接口的话,那么我们先来看下这个接口里面有什么方法呢:

1
2
3
4
5
//当请求 checkpoint 快照时,将调用此方法
void snapshotState(FunctionSnapshotContext context) throws Exception;

//在分布式执行期间创建并行功能实例时,将调用此方法。 函数通常在此方法中设置其状态存储数据结构
void initializeState(FunctionInitializationContext context) throws Exception;

当有请求执行 checkpoint 的时候,snapshotState() 方法就会被调用,initializeState() 方法会在每次初始化用户定义的函数时或者从更早的 checkpoint 恢复的时候被调用,因此 initializeState() 不仅是不同类型的状态被初始化的地方,而且还是 state 恢复逻辑的地方。

目前,List 类型的托管状态是支持的,状态被期望是一个可序列化的对象的 List,彼此独立,这样便于重分配,换句话说,这些对象是可以重新分配的 non-keyed state 的最小粒度,根据状态的访问方法,定义了重新分配的方案:

  • Even-split redistribution:每个算子会返回一个状态元素列表,整个状态在逻辑上是所有列表的连接。在重新分配或者恢复的时候,这个状态元素列表会被按照并行度分为子列表,每个算子会得到一个子列表。这个子列表可能为空,或包含一个或多个元素。举个例子,如果使用并行性 1,算子的检查点状态包含元素 element1 和 element2,当将并行性增加到 2 时,element1 可能最终在算子实例 0 中,而 element2 将转到算子实例 1 中。
  • Union redistribution:每个算子会返回一个状态元素列表,整个状态在逻辑上是所有列表的连接。在重新分配或恢复的时候,每个算子都会获得完整的状态元素列表。

如下示例是一个有状态的 SinkFunction 使用 CheckpointedFunction 来发送到外部之前缓存数据,使用了Even-split策略。

下面是一个有状态的 SinkFunction 的示例,它使用 CheckpointedFunction 来缓存数据,然后再将这些数据发送到外部系统,使用了 Even-split 策略:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public class BufferingSink implements SinkFunction<Tuple2<String, Integer>>, CheckpointedFunction {

private final int threshold;

private transient ListState<Tuple2<String, Integer>> checkpointedState;

private List<Tuple2<String, Integer>> bufferedElements;

public BufferingSink(int threshold) {
this.threshold = threshold;
this.bufferedElements = new ArrayList<>();
}

@Override
public void invoke(Tuple2<String, Integer> value, Context contex) throws Exception {
bufferedElements.add(value);
if (bufferedElements.size() == threshold) {
for (Tuple2<String, Integer> element: bufferedElements) {
//将数据发到外部系统
}
bufferedElements.clear();
}
}

@Override
public void snapshotState(FunctionSnapshotContext context) throws Exception {
checkpointedState.clear();
for (Tuple2<String, Integer> element : bufferedElements) {
checkpointedState.add(element);
}
}

@Override
public void initializeState(FunctionInitializationContext context) throws Exception {
ListStateDescriptor<Tuple2<String, Integer>> descriptor =
new ListStateDescriptor<>(
"buffered-elements",
TypeInformation.of(new TypeHint<Tuple2<String, Integer>>() {}));

checkpointedState = context.getOperatorStateStore().getListState(descriptor);

if (context.isRestored()) {
for (Tuple2<String, Integer> element : checkpointedState.get()) {
bufferedElements.add(element);
}
}
}
}

initializeState 方法将 FunctionInitializationContext 作为参数,它用来初始化 non-keyed 状态。注意状态是如何初始化的,类似于 Keyed state,StateDescriptor 包含状态名称和有关状态值的类型的信息:

1
2
3
4
5
6
ListStateDescriptor<Tuple2<String, Integer>> descriptor =
new ListStateDescriptor<>(
"buffered-elements",
TypeInformation.of(new TypeHint<Tuple2<Long, Long>>() {}));

checkpointedState = context.getOperatorStateStore().getListState(descriptor);

ListCheckpointed

是一种受限的 CheckpointedFunction,只支持 List 风格的状态和 even-spit 的重分配策略。该接口里面的方法有:

undefined

  • snapshotState(): 获取函数的当前状态。状态必须返回此函数先前所有的调用结果。
  • restoreState(): 将函数或算子的状态恢复到先前 checkpoint 的状态。此方法在故障恢复后执行函数时调用。如果函数的特定并行实例无法恢复到任何状态,则状态列表可能为空。

Stateful Source Functions

与其他算子相比,有状态的 source 函数需要注意的地方更多,比如为了保证状态的更新和结果的输出原子性,用户必须在 source 的 context 上加锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public static class CounterSource extends RichParallelSourceFunction<Long> implements ListCheckpointed<Long> {

//一次语义的当前偏移量
private Long offset = 0L;

//作业取消标志
private volatile boolean isRunning = true;

@Override
public void run(SourceContext<Long> ctx) {
final Object lock = ctx.getCheckpointLock();

while (isRunning) {
//输出和状态更新是原子性的
synchronized (lock) {
ctx.collect(offset);
offset += 1;
}
}
}

@Override
public void cancel() {
isRunning = false;
}

@Override
public List<Long> snapshotState(long checkpointId, long checkpointTimestamp) {
return Collections.singletonList(offset);
}

@Override
public void restoreState(List<Long> state) {
for (Long s : state)
offset = s;
}
}

或许有些算子想知道什么时候 checkpoint 全部做完了,可以参考使用 org.apache.flink.runtime.state.CheckpointListener 接口来实现,在该接口里面有 notifyCheckpointComplete 方法。

Broadcast State

Broadcast State 如何使用

前面提到了两种 Operator state 支持的动态扩展方法:even-split redistribution 和 union redistribution。Broadcast State 是 Flink 支持的另一种扩展方式,它用来支持将某一个流的数据广播到下游所有的 Task 中,数据都会存储在下游 Task 内存中,接收到广播的数据流后就可以在操作中利用这些数据,一般我们会将一些规则数据进行这样广播下去,然后其他的 Task 也都能根据这些规则数据做配置,更常见的就是规则动态的更新,然后下游还能够动态的感知。

Broadcast state 的特点是:

  • 使用 Map 类型的数据结构
  • 仅适用于同时具有广播流和非广播流作为数据输入的特定算子
  • 可以具有多个不同名称的 Broadcast state

那么我们该如何使用 Broadcast State 呢?下面通过一个例子来讲解一下,在这个例子中,我要广播的数据是监控告警的通知策略规则,然后下游拿到我这个告警通知策略去判断哪种类型的告警发到哪里去,该使用哪种方式来发,静默时间多长等。

第一个数据流是要处理的数据源,流中的对象具有告警或者恢复的事件,其中用一个 type 字段来标识哪个事件是告警,哪个事件是恢复,然后还有其他的字段标明是哪个集群的或者哪个项目的,简单代码如下:

1
2
3
DataStreamSource<AlertEvent> alertData = env.addSource(new FlinkKafkaConsumer011<>("alert",
new AlertEventSchema(),
parameterTool.getProperties()));

然后第二个数据流是要广播的数据流,它是告警通知策略数据(定时从 MySQL 中读取的规则表),简单代码如下:

1
2
3
4
5
6
7
8
9
10
DataStreamSource<Rule> alarmdata = env.addSource(new GetAlarmNotifyData());

// MapState 中保存 (RuleName, Rule) ,在描述类中指定 State name
MapStateDescriptor<String, Rule> ruleStateDescriptor = new MapStateDescriptor<>(
"RulesBroadcastState",
BasicTypeInfo.STRING_TYPE_INFO,
TypeInformation.of(new TypeHint<Rule>() {}));

// alarmdata 使用 MapStateDescriptor 作为参数广播,得到广播流
BroadcastStream<Rule> ruleBroadcastStream = alarmdata.broadcast(ruleStateDescriptor);

然后你要做的是将两个数据流进行连接,连接后再根据告警规则数据流的规则数据进行处理(这个告警的逻辑很复杂,我们这里就不再深入讲),伪代码大概如下:

1
2
3
4
5
6
7
alertData.connect(ruleBroadcastStream)
.process(
new KeyedBroadcastProcessFunction<AlertEvent, Rule>() {
//根据告警规则的数据进行处理告警事件
}
)
//可能还有更多的操作

alertData.connect(ruleBroadcastStream) 该 connect 方法将两个流连接起来后返回一个 BroadcastConnectedStream 对象,如果对 BroadcastConnectedStream 不太清楚的可以回看下文章 4如何使用 DataStream API 来处理数据? 再次复习一下。BroadcastConnectedStream 调用 process() 方法执行处理逻辑,需要指定一个逻辑实现类作为参数,具体是哪种实现类取决于非广播流的类型:

  • 如果非广播流是 keyed stream,需要实现 KeyedBroadcastProcessFunction
  • 如果非广播流是 non-keyed stream,需要实现 BroadcastProcessFunction

那么该怎么获取这个 Broadcast state 呢,它需要通过上下文来获取:

1
ctx.getBroadcastState(ruleStateDescriptor)

BroadcastProcessFunction 和 KeyedBroadcastProcessFunction

这两个抽象函数有两个相同的需要实现的接口:

  • processBroadcastElement():处理广播流中接收的数据元
  • processElement():处理非广播流数据的方法

用于处理非广播流是 non-keyed stream 的情况:

1
2
3
4
5
6
public abstract class BroadcastProcessFunction<IN1, IN2, OUT> extends BaseBroadcastProcessFunction {

public abstract void processElement(IN1 value, ReadOnlyContext ctx, Collector<OUT> out) throws Exception;

public abstract void processBroadcastElement(IN2 value, Context ctx, Collector<OUT> out) throws Exception;
}

用于处理非广播流是 keyed stream 的情况

1
2
3
4
5
6
7
8
public abstract class KeyedBroadcastProcessFunction<KS, IN1, IN2, OUT> {

public abstract void processElement(IN1 value, ReadOnlyContext ctx, Collector<OUT> out) throws Exception;

public abstract void processBroadcastElement(IN2 value, Context ctx, Collector<OUT> out) throws Exception;

public void onTimer(long timestamp, OnTimerContext ctx, Collector<OUT> out) throws Exception;
}

可以看到这两个接口提供的上下文对象有所不同。非广播方(processElement)使用 ReadOnlyContext,而广播方(processBroadcastElement)使用 Context。这两个上下文对象(简称 ctx)通用的方法接口有:

  • 访问 Broadcast state:ctx.getBroadcastState(MapStateDescriptor stateDescriptor)
  • 查询数据元的时间戳:ctx.timestamp()
  • 获取当前水印:ctx.currentWatermark()
  • 获取当前处理时间:ctx.currentProcessingTime()
  • 向旁侧输出(side-outputs)发送数据:ctx.output(OutputTag outputTag, X value)

这两者不同之处在于对 Broadcast state 的访问限制:广播方对其具有读和写的权限(read-write),非广播方只有读的权限(read-only),为什么要这么设计呢,主要是为了保证 Broadcast state 在算子的所有并行实例中是相同的。由于 Flink 中没有跨任务的通信机制,在一个任务实例中的修改不能在并行任务间传递,而广播端在所有并行任务中都能看到相同的数据元,只对广播端提供可写的权限。同时要求在广播端的每个并行任务中,对接收数据的处理是相同的。如果忽略此规则会破坏 State 的一致性保证,从而导致不一致且难以诊断的结果。也就是说,processBroadcast() 的实现逻辑必须在所有并行实例中具有相同的确定性行为。

使用 Broadcast state 需要注意

前面介绍了 Broadcast state,并将 BroadcastProcessFunction 和 KeyedBroadcastProcessFunction 做了个对比,那么接下来强调一下使用 Broadcast state 时需要注意的事项:

  • 没有跨任务的通信,这就是为什么只有广播方可以修改 Broadcast state 的原因。
  • 用户必须确保所有任务以相同的方式为每个传入的数据元更新 Broadcast state,否则可能导致结果不一致。
  • 跨任务的 Broadcast state 中的事件顺序可能不同,虽然广播的元素可以保证所有元素都将转到所有下游任务,但元素到达的顺序可能不一致。因此,Broadcast state 更新不能依赖于传入事件的顺序。
  • 所有任务都会把 Broadcast state 存入 checkpoint,虽然 checkpoint 发生时所有任务都具有相同的 Broadcast state。这是为了避免在恢复期间所有任务从同一文件中进行恢复(避免热点),然而代价是 state 在 checkpoint 时的大小成倍数(并行度数量)增加。
  • Flink 确保在恢复或改变并行度时不会有重复数据,也不会丢失数据。在具有相同或改小并行度后恢复的情况下,每个任务读取其状态 checkpoint。在并行度增大时,原先的每个任务都会读取自己的状态,新增的任务以循环方式读取前面任务的检查点。
  • 不支持 RocksDB state backend,Broadcast state 在运行时保存在内存中。

Queryable State

Queryable State,顾名思义,就是可查询的状态。

undefined

传统管理这些状态的方式是通过将计算后的状态结果存储在第三方 KV 存储中,然后由第三方应用去获取这些 KV 状态,但是在 Flink 种,现在有了 Queryable State,意味着允许用户对流的内部状态进行实时查询。

undefined

那么就不再像其他流计算框架,需要将结果存储到其他外部存储系统才能够被查询到,这样我们就可以不再需要等待状态写入外部存储(这块可能是其他系统的主要瓶颈之一),甚至可以做到无需任何数据库就可以让用户直接查询到数据,这使得数据获取到的时间会更短,更及时,如果你有这块的需求(需要将某些状态数据进行展示,比如数字大屏),那么就强烈推荐使用 Queryable State。目前可查询的 state 主要针对可分区的 state,如 keyed state 等。

在 Flink 源码中,为此还专门有一个 module 来讲 Queryable State 呢!

undefined

那么我们该如何使用 Queryable State 呢?有如下两种方式 :

  • QueryableStateStream, 将 KeyedStream 转换为 QueryableStateStream,类似于 Sink,后续不能进行任何转换操作
  • StateDescriptor#setQueryable(String queryableStateName),将 Keyed State 设置为可查询的 (不支持 Operator State)

外部应用在查询 Flink 应用程序内部状态的时候要使用 QueryableStateClient, 提交异步查询请求来获取状态。如何使状态可查询呢,假如已经创建了一个状态可查询的 Job,并通过 JobClient 提交 Job,那么它在 Flink 内部的具体实现如下图(图片来自 Queryable States in ApacheFlink - How it works)所示:

undefined

上面讲解了让 State 可查询的原理,如果要在 Flink 集群中使用的话,首先得将 Flink 安装目录下 opt 里面的 flink-queryable-state-runtime_2.11-1.9.0.jar 复制到 lib 目录下,默认 lib 目录是不包含这个 jar 的。

undefined

然后你可以像下面这样操作让状态可查询:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Reducing state
ReducingStateDescriptor<Tuple2<Integer, Long>> reducingState = new ReducingStateDescriptor<>(
"zhisheng",
new SumReduce(),
source.getType());

final String queryName = "zhisheng";

final QueryableStateStream<Integer, Tuple2<Integer, Long>> queryableState =
dataStream.keyBy(new KeySelector<Tuple2<Integer, Long>, Integer>() {
private static final long serialVersionUID = -4126824763829132959L;
@Override
public Integer getKey(Tuple2<Integer, Long> value) {
return value.f0;
}
}).asQueryableState(queryName, reducingState);

除了上面的 Reducing,你还可以使用 ValueState、FoldingState,还可以直接通过asQueryableState(queryName),注意不支持 ListState,调用 asQueryableState 方法后会返回 QueryableStateStream,接着无需再做其他操作。

那么用户如果定义了 Queryable State 的话,该怎么来查询对应的状态呢?下面来看看具体逻辑:

undefined

简单来说,当用户在 Job 中定义了 queryable state 之后,就可以在外部通过QueryableStateClient 来查询对应的状态实时值,你可以创建如下方法:

1
2
3
4
5
6
7
8
9
//创建 Queryable State Client
QueryableStateClient client = new QueryableStateClient(host, port);

public QueryableStateClient(final InetAddress remoteAddress, final int remotePort) {
...
this.client = new Client<>(
"Queryable State Client", 1,
messageSerializer, new DisabledKvStateRequestStats());
}

在 QueryableStateClient 中有几个不同参数的 getKvState 方法,参数可有 JobID、queryableStateName、key、namespace、keyTypeInfo、namespaceTypeInfo、StateDescriptor,其实内部最后调用的是一个私有的 getKvState 方法:

1
2
3
4
5
6
7
8
9
10
private CompletableFuture<KvStateResponse> getKvState(
final JobID jobId, final String queryableStateName,
final int keyHashCode, final byte[] serializedKeyAndNamespace) {
...
//构造 KV state 查询的请求
KvStateRequest request = new KvStateRequest(jobId, queryableStateName, keyHashCode, serializedKeyAndNamespace);
//这个 client 是在构造 QueryableStateClient 中赋值的,这个 client 是 Client<KvStateRequest, KvStateResponse>,发送请求后会返回 CompletableFuture<KvStateResponse>
return client.sendRequest(remoteAddress, request);
...
}

在 Flink 源码中专门有一个 QueryableStateOptions 类来设置可查询状态相关的配置,有如下这些配置。

服务器端:

  • queryable-state.proxy.ports:可查询状态代理的服务器端口范围的配置参数,默认是 9069
  • queryable-state.proxy.network-threads:客户端代理的网络线程数,默认是 0
  • queryable-state.proxy.query-threads:客户端代理的异步查询线程数,默认是 0
  • queryable-state.server.ports:可查询状态服务器的端口范围,默认是 9067
  • queryable-state.server.network-threads:KvState 服务器的网络线程数
  • queryable-state.server.query-threads:KvStateServerHandler 的异步查询线程数
  • queryable-state.enable:是否启用可查询状态代理和服务器

客户端:

  • queryable-state.client.network-threads:KvState 客户端的网络线程数

注意

可查询状态的生命周期受限于 Job 的生命周期,例如,任务在启动时注册可查询状态,在清理的时候会注销它。在未来的版本中,可能会将其解耦,以便在任务完成后仍可以允许查询到任务的状态。

小结与反思

本节一开始讲解了 State 出现的原因,接着讲解了 Flink 中的 State 分类,然后对 Flink 中的每种 State 做了详细的讲解,希望可以好好消化这节的内容。你对本节的内容有什么不理解的地方吗?在使用 State 的过程中有遇到什么问题吗?

State Backends

当需要对具体的某一种 State 做 Checkpoint 时,此时就需要具体的状态后端存储,刚好 Flink 内置提供了不同的状态后端存储,用于指定状态的存储方式和位置。状态可以存储在 Java 堆内存中或者堆外,在 Flink 安装路径下 conf 目录中的 flink-conf.yaml 配置文件中也有状态后端存储相关的配置,为此在 Flink 源码中还特有一个 CheckpointingOptions 类来控制 state 存储的相关配置,该类中有如下配置:

  • state.backend: 用于存储和进行状态 checkpoint 的状态后端存储方式,无默认值
  • state.checkpoints.num-retained: 要保留的已完成 checkpoint 的最大数量,默认值为 1
  • state.backend.async: 状态后端是否使用异步快照方法,默认值为 true
  • state.backend.incremental: 状态后端是否创建增量检查点,默认值为 false
  • state.backend.local-recovery: 状态后端配置本地恢复,默认情况下,本地恢复被禁用
  • taskmanager.state.local.root-dirs: 定义存储本地恢复的基于文件的状态的目录
  • state.savepoints.dir: 存储 savepoints 的目录
  • state.checkpoints.dir: 存储 checkpoint 的数据文件和元数据
  • state.backend.fs.memory-threshold: 状态数据文件的最小大小,默认值是 1024

虽然配置这么多,但是,Flink 还支持基于每个 Job 单独设置状态后端存储,方法如下:

1
2
3
4
5
6
7
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

env.setStateBackend(new MemoryStateBackend()); //设置堆内存存储

//env.setStateBackend(new FsStateBackend(checkpointDir, asyncCheckpoints)); //设置文件存储

//env.setStateBackend(new RocksDBStateBackend(checkpointDir, incrementalCheckpoints)); //设置 RocksDB 存储

undefined

上面三种方式取一种就好了。但是有三种方式,我们该如何去挑选用哪种去存储状态呢?下面讲讲这三种的特点以及该如何选择。

如何使用 MemoryStateBackend 及剖析

如果 Job 没有配置指定状态后端存储的话,就会默认采取 MemoryStateBackend 策略。如果你细心的话,可以从你的 Job 中看到类似日志如下:

1
2019-04-28 00:16:41.892 [Sink: zhisheng (1/4)] INFO  org.apache.flink.streaming.runtime.tasks.StreamTask  - No state backend has been configured, using default (Memory / Job Manager) MemoryStateBackend (data in heap memory / checkpoints to Job Manager) (checkpoints: 'null', savepoints: 'null', asynchronous: TRUE, maxStateSize: 5242880)

上面日志的意思就是说如果没有配置任何状态存储,使用默认的 MemoryStateBackend 策略,这种状态后端存储把数据以内部对象的形式保存在 Task Managers 的内存(JVM 堆)中,当应用程序触发 checkpoint 时,会将此时的状态进行快照然后存储在 Job Manager 的内存中。因为状态是存储在内存中的,所以这种情况会有点限制,比如:

  • 不太适合在生产环境中使用,仅用于本地测试的情况较多,主要适用于状态很小的 Job,因为它会将状态最终存储在 Job Manager 中,如果状态较大的话,那么会使得 Job Manager 的内存比较紧张,从而导致 Job Manager 会出现 OOM 等问题,然后造成连锁反应使所有的 Job 都挂掉,所以 Job 的状态与之前的 Checkpoint 的数据所占的内存要小于 JobManager 的内存。
  • 每个单独的状态大小不能超过最大的 DEFAULTMAXSTATE_SIZE(5MB),可以通过构造 MemoryStateBackend 参数传入不同大小的 maxStateSize。
  • Job 的操作符状态和 keyed 状态加起来都不要超过 RPC 系统的默认配置 10 MB,虽然可以修改该配置,但是不建议去修改。

另外就是 MemoryStateBackend 支持配置是否是异步快照还是同步快照,它有一个字段 asynchronousSnapshots 来表示,可选值有:

  • TRUE(true 代表使用异步的快照,这样可以避免因快照而导致数据流处理出现阻塞等问题)
  • FALSE(同步)
  • UNDEFINED(默认值)

在构造 MemoryStateBackend 的默认函数时是使用的 UNDEFINED,而不是异步:

1
2
3
public MemoryStateBackend() {
this(null, null, DEFAULT_MAX_STATE_SIZE, TernaryBoolean.UNDEFINED);//使用的是 UNDEFINED
}

网上有人说默认是异步的,这里给大家解释清楚一下,从上面的那条日志打印的确实也是表示异步,但是前提是你对 State 无任何操作,我跟了下源码,当你没有配置任何的 state 时,它是会在 StateBackendLoader 类中通过 MemoryStateBackendFactory 来创建的 state 的。

undefined

继续跟进 MemoryStateBackendFactory 可以发现他这里创建了一个 MemoryStateBackend 实例并通过 configure 方法进行配置,大概流程代码是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//MemoryStateBackendFactory 类
public MemoryStateBackend createFromConfig(Configuration config, ClassLoader classLoader) {
return new MemoryStateBackend().configure(config, classLoader);
}

//MemoryStateBackend 类中的 config 方法
public MemoryStateBackend configure(Configuration config, ClassLoader classLoader) {
return new MemoryStateBackend(this, config, classLoader);
}

//私有的构造方法
private MemoryStateBackend(MemoryStateBackend original, Configuration configuration, ClassLoader classLoader) {
...
this.asynchronousSnapshots = original.asynchronousSnapshots.resolveUndefined(
configuration.getBoolean(CheckpointingOptions.ASYNC_SNAPSHOTS));
}

//根据 CheckpointingOptions 类中的 ASYNC_SNAPSHOTS 参数进行设置的
public static final ConfigOption<Boolean> ASYNC_SNAPSHOTS = ConfigOptions
.key("state.backend.async")
.defaultValue(true) //默认值就是 true,代表异步
.withDescription(...)

可以发现最终是通过读取 state.backend.async 参数的默认值(true)来配置是否要异步的进行快照,但是如果你手动配置 MemoryStateBackend 的话,利用无参数的构造方法,那么就不是默认异步,如果想使用异步的话,需要利用下面这个构造函数(需要传入一个 boolean 值,true 代表异步,false 代表同步):

1
2
3
public MemoryStateBackend(boolean asynchronousSnapshots) {
this(null, null, DEFAULT_MAX_STATE_SIZE, TernaryBoolean.fromBoolean(asynchronousSnapshots));
}

如果你再细看了这个 MemoryStateBackend 类的话,那么你可能会发现这个构造函数:

1
2
3
public MemoryStateBackend(@Nullable String checkpointPath, @Nullable String savepointPath) {
this(checkpointPath, savepointPath, DEFAULT_MAX_STATE_SIZE, TernaryBoolean.UNDEFINED);//需要你传入 checkpointPath 和 savepointPath
}

这个也是用来创建一个 MemoryStateBackend 的,它需要传入的参数是两个路径(checkpointPath、savepointPath),其中 checkpointPath 是写入 checkpoint 元数据的路径,savepointPath 是写入 savepoint 的路径。

这个来看看 MemoryStateBackend 的继承关系图可以更明确的知道它是继承自 AbstractFileStateBackend,然后 AbstractFileStateBackend 这个抽象类就是为了能够将状态存储中的数据或者元数据进行文件存储的。

undefined

所以 FsStateBackend 和 MemoryStateBackend 都会继承该类。

如何使用 FsStateBackend 及剖析

这种状态后端存储也是将工作状态存储在 Task Manager 中的内存(JVM 堆)中,但是 checkpoint 的时候,它和 MemoryStateBackend 不一样,它是将状态存储在文件(可以是本地文件,也可以是 HDFS)中,这个文件具体是哪种需要配置,比如:”hdfs://namenode:40010/flink/checkpoints” 或 “file://flink/checkpoints” (通常使用 HDFS 比较多,如果是使用本地文件,可能会造成 Job 恢复的时候找不到之前的 checkkpoint,因为 Job 重启后如果由调度器重新分配在不同的机器的 Task Manager 执行时就会导致这个问题,所以还是建议使用 HDFS 或者其他的分布式文件系统)。

同样 FsStateBackend 也是支持通过 asynchronousSnapshots 字段来控制是使用异步还是同步来进行 checkpoint 的,异步可以避免在状态 checkpoint 时阻塞数据流的处理,然后还有一点的就是在 FsStateBackend 有个参数 fileStateThreshold,如果状态大小比 MAXFILESTATE_THRESHOLD(1MB) 小的话,那么会将状态数据直接存储在 meta data 文件中,而不是存储在配置的文件中(避免出现很小的状态文件),如果该值为 “-1” 表示尚未配置,在这种情况下会使用默认值(1024,该默认值可以通过 state.backend.fs.memory-threshold 来配置)。

那么我们该什么时候使用 FsStateBackend 呢?

  • 如果你要处理大状态,长窗口等有状态的任务,那么 FsStateBackend 就比较适合
  • 使用分布式文件系统,如 HDFS 等,这样 failover 时 Job 的状态可以恢复

使用 FsStateBackend 需要注意的地方有什么呢?

  • 工作状态仍然是存储在 Task Manager 中的内存中,虽然在 Checkpoint 的时候会存在文件中,所以还是得注意这个状态要保证不超过 Task Manager 的内存

如何使用 RocksDBStateBackend 及剖析

RocksDBStateBackend 和上面两种都有点不一样,RocksDB 是一种嵌入式的本地数据库,它会在本地文件系统中维护状态,KeyedStateBackend 等会直接写入本地 RocksDB 中,它还需要配置一个文件系统(一般是 HDFS),比如 hdfs://namenode:40010/flink/checkpoints,当触发 checkpoint 的时候,会把整个 RocksDB 数据库复制到配置的文件系统中去,当 failover 时从文件系统中将数据恢复到本地。

在 Flink 源码中,你也可以看见专门有一个 module 是 flink-statebackend-rocksdb 来放在 flink-state-backends 下面,在后面的版本中可能还会加上 flink-statebackend-heap-spillable 模块用来当作一种新的状态后端存储,感兴趣可以去官网的计划中查看。

undefined

足以证明了官方其实也是推荐使用 RocksDB 来作为状态的后端存储,为什么呢:

  • state 直接存放在 RocksDB 中,不需要存在内存中,这样就可以减少 Task Manager 的内存压力,如果是存内存的话大状态的情况下会导致 GC 次数比较多,同时还能在 checkpoint 时将状态持久化到远端的文件系统,那么就比较适合在生产环境中使用
  • RocksDB 本身支持 checkpoint 功能
  • RocksDBStateBackend 支持增量的 checkpoint,在 RocksDBStateBackend 中有一个字段 enableIncrementalCheckpointing 来确认是否开启增量的 checkpoint,默认是不开启的,在 CheckpointingOptions 类中有个 state.backend.incremental 参数来表示,增量 checkpoint 非常使用于超大状态的场景。

讲了这么多 RocksDBStateBackend 的好处,那么该如何去使用呢,可以来看看 RocksDBStateBackend 这个类的相关属性以及构造函数。

属性

  • checkpointStreamBackend:用于创建 checkpoint 流的状态后端
  • localRocksDbDirectories:RocksDB 目录的基本路径,默认是 Task Manager 的临时目录
  • enableIncrementalCheckpointing:是否增量 checkpoint
  • numberOfTransferingThreads:用于传输(下载和上传)状态的线程数量,默认为 1
  • enableTtlCompactionFilter:是否启用压缩过滤器来清除带有 TTL 的状态

构造函数

  • RocksDBStateBackend(String checkpointDataUri):单参数,只传入一个路径
  • RocksDBStateBackend(String checkpointDataUri, boolean enableIncrementalCheckpointing):两个参数,传入 checkpoint 数据目录路径和是否开启增量 checkpoint
  • RocksDBStateBackend(StateBackend checkpointStreamBackend):传入一种 StateBackend
  • RocksDBStateBackend(StateBackend checkpointStreamBackend, TernaryBoolean enableIncrementalCheckpointing):传入一种 StateBackend 和是否开启增量 checkpoint
  • RocksDBStateBackend(RocksDBStateBackend original, Configuration config, ClassLoader classLoader):私有的构造方法,用于重新配置状态后端

既然知道这么多构造函数了,那么使用就很简单了,根据你的场景考虑使用哪种构造函数创建 RocksDBStateBackend 对象就行了,然后通过 env.setStateBackend() 传入对象实例就行,如下所示:

1
//env.setStateBackend(new RocksDBStateBackend(checkpointDir, incrementalCheckpoints));  //设置 RocksDB 存储

那么在使用 RocksDBStateBackend 时该注意什么呢:

  • 当使用 RocksDB 时,状态大小将受限于磁盘可用空间的大小
  • 状态存储在 RocksDB 中,整个更新和获取状态的操作都是要通过序列化和反序列化才能完成的,跟状态直接存储在内存中,性能可能会略低些
  • 如果你应用程序的状态很大,那么使用 RocksDB 无非是最佳的选择

另外在 Flink 源码中有一个专门的 RocksDBOptions 来表示 RocksDB 相关的配置:

  • state.backend.rocksdb.localdir:本地目录(在 Task Manager 上),RocksDB 将其文件放在其中
  • state.backend.rocksdb.timer-service.factory:定时器服务实现,默认值是 HEAP
  • state.backend.rocksdb.checkpoint.transfer.thread.num:用于在后端传输(下载和上载)文件的线程数,默认是 1
  • state.backend.rocksdb.ttl.compaction.filter.enabled:是否启用压缩过滤器来清除带有 TTL 的状态,默认值是 false

如何选择状态后端存储?

通过上面三种 State Backends 的介绍,让大家了解了状态存储有哪些种类,然后对每种状态存储是该如何使用的、它们内部的实现、使用场景、需要注意什么都细讲了一遍,三种存储方式各有特点,可以满足不同场景的需求,通常来说,在开发程序之前,我们要先分析自己 Job 的场景和状态大小的预测,然后根据预测来进行选择何种状态存储,如果拿捏不定的话,建议先在测试环境进行测试,只有选择了正确的状态存储后端,这样才能够保证后面自己的 Job 在生产环境能够稳定的运行。

小结与反思

本节对 Flink 的 State 做了一个很详尽的讲解,不管是从使用方面,还从原理进行深度分析,涉及的有 State 的分类如 Keyed State、Operator State、Raw State、 Managed State、Broadcast State 等。还讲了如何让 State 进行可查询的配置,State 的过期,最后还讲了 State 的三种常见的后端存储方式,并分析了三者适合于哪种场景,同时也都对这几种方式的源码进行解读,目的就是让大家对 State 彻底的了解使用方式和原理实现。

下面一图来看看 State 在 Flink 中的整体结构:

undefined

Checkpoint 在 Flink 中是一个非常重要的 Feature,Checkpoint 使 Flink 的状态具有良好的容错性,通过 Checkpoint 机制,Flink 可以对作业的状态和计算位置进行恢复。本节主要讲述在 Flink 中 Checkpoint 和 Savepoint 的使用方式及它们之间的区别。

Checkpoint 介绍及使用

为了保障的容错,Flink 需要对状态进行快照。Flink 可以从 Checkpoint 中恢复流的状态和位置,从而使得应用程序发生故障后能够得到与无故障执行相同的语义。

Flink 的 Checkpoint 有以下先决条件:

  • 需要具有持久性且支持重放一定时间范围内数据的数据源。例如:Kafka、RabbitMQ 等。这里为什么要求支持重放一定时间范围内的数据呢?因为 Flink 的容错机制决定了,当 Flink 任务失败后会自动从最近一次成功的 Checkpoint 处恢复任务,此时可能需要把任务失败前消费的部分数据再消费一遍,所以必须要求数据源支持重放。假如一个Flink 任务消费 Kafka 并将数据写入到 MySQL 中,任务从 Kafka 读取到数据,还未将数据输出到 MySQL 时任务突然失败了,此时如果 Kafka 不支持重放,就会造成这部分数据永远丢失了。支持重放数据的数据源可以保障任务消费失败后,能够重新消费来保障任务不丢数据。
  • 需要一个能保存状态的持久化存储介质,例如:HDFS、S3 等。当 Flink 任务失败后,自动从 Checkpoint 处恢复,但是如果 Checkpoint 时保存的状态信息快照全丢了,那就会影响 Flink 任务的正常恢复。就好比我们看书时经常使用书签来记录当前看到的页码,当下次看书时找到书签的位置继续阅读即可,但是如果书签三天两头经常丢,那我们就无法通过书签来恢复阅读。

Flink 中 Checkpoint 是默认关闭的,对于需要保障 At Least Once 和 Exactly Once 语义的任务,强烈建议开启 Checkpoint,对于丢一小部分数据不敏感的任务,可以不开启 Checkpoint,例如:一些推荐相关的任务丢一小部分数据并不会影响推荐效果。下面来介绍 Checkpoint 具体如何使用。

首先调用 StreamExecutionEnvironment 的方法 enableCheckpointing(n) 来开启 Checkpoint,参数 n 以毫秒为单位表示 Checkpoint 的时间间隔。Checkpoint 配置相关的 Java 代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

// 开启 Checkpoint,每 1000毫秒进行一次 Checkpoint
env.enableCheckpointing(1000);

// Checkpoint 语义设置为 EXACTLY_ONCE
env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE);

// CheckPoint 的超时时间
env.getCheckpointConfig().setCheckpointTimeout(60000);

// 同一时间,只允许 有 1 个 Checkpoint 在发生
env.getCheckpointConfig().setMaxConcurrentCheckpoints(1);

// 两次 Checkpoint 之间的最小时间间隔为 500 毫秒
env.getCheckpointConfig().setMinPauseBetweenCheckpoints(500);

// 当 Flink 任务取消时,保留外部保存的 CheckPoint 信息
env.getCheckpointConfig().enableExternalizedCheckpoints(ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION);

// 当有较新的 Savepoint 时,作业也会从 Checkpoint 处恢复
env.getCheckpointConfig().setPreferCheckpointForRecovery(true);

// 作业最多允许 Checkpoint 失败 1 次(flink 1.9 开始支持)
env.getCheckpointConfig().setTolerableCheckpointFailureNumber(1);

// Checkpoint 失败后,整个 Flink 任务也会失败(flink 1.9 之前)
env.getCheckpointConfig.setFailTasksOnCheckpointingErrors(true)

以上 Checkpoint 相关的参数描述如下所示:

  • Checkpoint 语义:EXACTLYONCE 或 ATLEASTONCE,EXACTLYONCE 表示所有要消费的数据被恰好处理一次,即所有数据既不丢数据也不重复消费;ATLEASTONCE 表示要消费的数据至少处理一次,可能会重复消费。
  • Checkpoint 超时时间:如果 Checkpoint 时间超过了设定的超时时间,则 Checkpoint 将会被终止。
  • 同时进行的 Checkpoint 数量:默认情况下,当一个 Checkpoint 在进行时,JobManager 将不会触发下一个 Checkpoint,但 Flink 允许多个 Checkpoint 同时在发生。
  • 两次 Checkpoint 之间的最小时间间隔:从上一次 Checkpoint 结束到下一次 Checkpoint 开始,中间的间隔时间。例如,env.enableCheckpointing(60000) 表示 1 分钟触发一次 Checkpoint,同时再设置两次 Checkpoint 之间的最小时间间隔为 30 秒,假如任务运行过程中一次 Checkpoint 就用了50s,那么等 Checkpoint 结束后,理论来讲再过 10s 就要开始下一次 Checkpoint 了,但是由于设置了最小时间间隔为30s,所以需要再过 30s 后,下次 Checkpoint 才开始。注:如果配置了该参数就决定了同时进行的 Checkpoint 数量只能为 1。
  • 当任务被取消时,外部 Checkpoint 信息是否被清理:Checkpoint 在默认的情况下仅用于恢复运行失败的 Flink 任务,当任务手动取消时 Checkpoint 产生的状态信息并不保留。当然可以通过该配置来保留外部的 Checkpoint 状态信息,这些被保留的状态信息在作业手动取消时不会被清除,这样就可以使用该状态信息来恢复 Flink 任务,对于需要从状态恢复的任务强烈建议配置为外部 Checkpoint 状态信息不清理。可选择的配置项为:
  • ExternalizedCheckpointCleanup.RETAINONCANCELLATION:当作业手动取消时,保留作业的 Checkpoint 状态信息。注意,这种情况下,需要手动清除该作业保留的 Checkpoint 状态信息,否则这些状态信息将永远保留在外部的持久化存储中。
  • ExternalizedCheckpointCleanup.DELETEONCANCELLATION:当作业取消时,Checkpoint 状态信息会被删除。仅当作业失败时,作业的 Checkpoint 才会被保留用于任务恢复。
  • 任务失败,当有较新的 Savepoint 时,作业是否回退到 Checkpoint 进行恢复:默认情况下,当 Savepoint 比 Checkpoint 较新时,任务会从 Savepoint 处恢复。
  • 作业可以容忍 Checkpoint 失败的次数:默认值为 0,表示不能接受 Checkpoint 失败。

关于 Checkpoint 时,状态后端相关的配置请参阅本课 4.2 节。

Savepoint 介绍、Savepoint 与 Checkpoint 的区别及使用

Savepoint 与 Checkpoint 类似,同样需要把状态信息存储到外部介质,当作业失败时,可以从外部存储中恢复。Savepoint 与 Checkpoint 的区别很多:

Checkpoint Savepoint
由 Flink 的 JobManager 定时自动触发并管理 由用户手动触发并管理
主要用于任务发生故障时,为任务提供给自动恢复机制 主要用户升级 Flink 版本、修改任务的逻辑代码、调整算子的并行度,且必须手动恢复
当使用 RocksDBStateBackend 时,支持增量方式对状态信息进行快照 仅支持全量快照
Flink 任务停止后,Checkpoint 的状态快照信息默认被清除 一旦触发 Savepoint,状态信息就被持久化到外部存储,除非用户手动删除
Checkpoint 设计目标:轻量级且尽可能快地恢复任务 Savepoint 的生成和恢复成本会更高一些,Savepoint 更多地关注代码的可移植性和兼容任务的更改操作

除了上述描述外,Checkpoint 和 Savepoint 在当前的实现上基本相同。

强烈建议在程序中给算子分配 Operator ID,以便来升级程序。主要通过 uid(String) 方法手动指定算子的 ID ,这些 ID 将用于恢复每个算子的状态。

1
2
3
4
5
6
7
8
9
10
DataStream<String> stream = env.
// Stateful source (e.g. Kafka) with ID
.addSource(new StatefulSource())
.uid("source-id") // ID for the source operator
.shuffle()
// Stateful mapper with ID
.map(new StatefulMapper())
.uid("mapper-id") // ID for the mapper
// Stateless printing sink
.print(); // Auto-generated ID

如果不为算子手动指定 ID,Flink 会为算子自动生成 ID。当 Flink 任务从 Savepoint 中恢复时,是按照 Operator ID 将快照信息与算子进行匹配的,只要这些 ID 不变,Flink 任务就可以从 Savepoint 中恢复。自动生成的 ID 取决于代码的结构,并且对代码更改比较敏感,因此强烈建议给程序中所有有状态的算子手动分配 Operator ID。如下左图所示,一个 Flink 任务包含了 算子 A 和 算子 B,代码中都未指定 Operator ID,所以 Flink 为 Task A 自动生成了 Operator ID 为 aaa,为 Task B 自动生成了 Operator ID 为 bbb,且 Savepoint 成功完成。但是在代码改动后,任务并不能从 Savepoint 中正常恢复,因为 Flink 为算子生成的 Operator ID 取决于代码结构,代码改动后可能会把算子 B 的 Operator ID 改变成 ccc,导致任务从 Savepoint 恢复时,SavePoint 中只有 Operator ID 为 aaa 和 bbb 的状态信息,算子 B 找不到 Operator ID 为 ccc 的状态信息,所以算子 B 不能正常恢复。这里如果在写代码时通过 uid(String) 手动指定了 Operator ID,就不会存在 上述问题了。

undefined

Savepoint 需要用户手动去触发,触发 Savepoint 的方式如下所示:

1
bin/flink savepoint :jobId [:targetDirectory]

这将触发 ID 为 :jobId 的作业进行 Savepoint,并返回创建的 Savepoint 路径,用户需要此路径来还原和删除 Savepoint 。

使用 YARN 触发 Savepoint 的方式如下所示:

1
bin/flink savepoint :jobId [:targetDirectory] -yid :yarnAppId

这将触发 ID 为 :jobId 和 YARN 应用程序 ID :yarnAppId 的作业进行 Savepoint,并返回创建的 Savepoint 路径。

使用 Savepoint 取消 Flink 任务:

1
bin/flink cancel -s [:targetDirectory] :jobId

这将自动触发 ID 为 :jobid 的作业进行 Savepoint,并在 Checkpoint 结束后取消该任务。此外,可以指定一个目标文件系统目录来存储 Savepoint 的状态信息,也可以在 flink 的 conf 目录下 flink-conf.yaml 中配置 state.savepoints.dir 参数来指定 Savepoint 的默认目录,触发 Savepoint 时,如果不指定目录则使用该默认目录。无论使用哪种方式配置,都需要保障配置的目录能被所有的 JobManager 和 TaskManager 访问。

Checkpoint 流程

Flink 任务 Checkpoint 的详细流程如下所示:

\1. JobManager 端的 CheckPointCoordinator 会定期向所有 SourceTask 发送 CheckPointTrigger,Source Task 会在数据流中安插 Checkpoint barrier

undefined

  1. 当 task 收到上游所有实例的 barrier 后,向自己的下游继续传递 barrier,然后自身同步进行快照,并将自己的状态异步写入到持久化存储中
  • 如果是增量 Checkpoint,则只是把最新的一部分更新写入到外部持久化存储中
  • 为了下游尽快进行 Checkpoint,所以 task 会先发送 barrier 到下游,自身再同步进行快照

undefined

注:Task B 必须接收到上游 Task A 所有实例发送的 barrier 时,Task B 才能开始进行快照,这里有一个 barrier 对齐的概念,关于 barrier 对齐的详细介绍请参阅 9.5.1 节 Flink 内部如何保证 Exactly Once 中的 barrier 对齐部分

  1. 当 task 将状态信息完成备份后,会将备份数据的地址(state handle)通知给 JobManager 的CheckPointCoordinator,如果 Checkpoint 的持续时长超过了 Checkpoint 设定的超时时间CheckPointCoordinator 还没有收集完所有的 State Handle,CheckPointCoordinator 就会认为本次 Checkpoint 失败,会把这次 Checkpoint 产生的所有状态数据全部删除

  2. 如果 CheckPointCoordinator 收集完所有算子的 State Handle,CheckPointCoordinator 会把整个 StateHandle 封装成 completed Checkpoint Meta,写入到外部存储中,Checkpoint 结束

undefined

如果对上述 Checkpoint 过程不理解,在后续 9.5 节 Flink 如何保障 Exactly Once 中会详细介绍 Flink 的 Checkpoint 过程以及为什么这么做。

基于 RocksDB 的增量 Checkpoint 实现原理

当使用 RocksDBStateBackend 时,增量 Checkpoint 是如何实现的呢?RocksDB 是一个基于 LSM 实现的 KV 数据库。LSM 全称 Log Structured Merge Trees,LSM 树本质是将大量的磁盘随机写操作转换成磁盘的批量写操作来极大地提升磁盘数据写入效率。一般 LSM Tree 实现上都会有一个基于内存的 MemTable 介质,所有的增删改操作都是写入到 MemTable 中,当 MemTable 足够大以后,将 MemTable 中的数据 flush 到磁盘中生成不可变且内部有序的 ssTable(Sorted String Table)文件,全量数据保存在磁盘的多个 ssTable 文件中。HBase 也是基于 LSM Tree 实现的,HBase 磁盘上的 HFile 就相当于这里的 ssTable 文件,每次生成的 HFile 都是不可变的而且内部有序的文件。基于 ssTable 不可变的特性,才实现了增量 Checkpoint,具体流程如下所示:

undefined

第一次 Checkpoint 时生成的状态快照信息包含了两个 sstable 文件:sstable1 和 sstable2 及 Checkpoint1 的元数据文件 MANIFEST-chk1,所以第一次 Checkpoint 时需要将 sstable1、sstable2 和 MANIFEST-chk1 上传到外部持久化存储中。第二次 Checkpoint 时生成的快照信息为 sstable1、sstable2、sstable3 及元数据文件 MANIFEST-chk2,由于 sstable 文件的不可变特性,所以状态快照信息的 sstable1、sstable2 这两个文件并没有发生变化,sstable1、sstable2 这两个文件不需要重复上传到外部持久化存储中,因此第二次 Checkpoint 时,只需要将 sstable3 和 MANIFEST-chk2 文件上传到外部持久化存储中即可。这里只将新增的文件上传到外部持久化存储,也就是所谓的增量 Checkpoint。

基于 LSM Tree 实现的数据库为了提高查询效率,都需要定期对磁盘上多个 sstable 文件进行合并操作,合并时会将删除的、过期的以及旧版本的数据进行清理,从而降低 sstable 文件的总大小。图中可以看到第三次 Checkpoint 时生成的快照信息为sstable3、sstable4、sstable5 及元数据文件 MANIFEST-chk3, 其中新增了 sstable4 文件且 sstable1 和 sstable2 文件合并成 sstable5 文件,因此第三次 Checkpoint 时只需要向外部持久化存储上传 sstable4、sstable5 及元数据文件 MANIFEST-chk3。

基于 RocksDB 的增量 Checkpoint 从本质上来讲每次 Checkpoint 时只将本次 Checkpoint 新增的快照信息上传到外部的持久化存储中,依靠的是 LSM Tree 中 sstable 文件不可变的特性。对 LSM Tree 感兴趣的同学可以深入研究 RocksDB 或 HBase 相关原理及实现。

状态如何从 Checkpoint 恢复

在 Checkpoint 和 Savepoint 的比较过程中,知道了相比 Savepoint 而言,Checkpoint 的成本更低一些,但有些场景 Checkpoint 并不能完全满足我们的需求。所以在使用过程中,如果我们的需求能使用 Checkpoint 来解决优先使用 Checkpoint。当 Flink 任务中的一些依赖组件需要升级重启时,例如 hdfs、Kafka、yarn 升级或者 Flink 任务的 Sink 端对应的 MySQL、Redis 由于某些原因需要重启时,Flink 任务在这段时间也需要重启。但是由于 Flink 任务的代码并没有修改,所以 Flink 任务启动时可以从 Checkpoint 处恢复任务,此时必须配置取消 Flink 任务时保留外部存储的 Checkpoint 状态信息。从 Checkpoint 处恢复任务的命令如下所示,checkpointMetaDataPath 表示 Checkpoint 的目录。

1
bin/flink run -s :checkpointMetaDataPath xxx.jar [:runArgs]

如果 flink on yarn 模式,启动命令如下所示:

1
bin/flink run -s :checkpointMetaDataPath -yid :yarnAppId xxx.jar [:runArgs]

问题来了,Flink 自动维护 Checkpoint,所以用户在这里并拿不到任务取消之前最后一次 Checkpoint 的目录。那怎么办呢?如下图所示,在任务取消之前,Flink 任务的 WebUI 中可以看到 Checkpoint 的目录,可以在取消任务之前将此目录保存起来,恢复时就可以从该目录恢复任务。

undefined

上述方法最大缺陷就是用户的人力成本太高了,假如需要重启 100 个任务,难道需要用户手动维护 100 个任务的 Checkpoint 目录吗?可以做一个简单后台项目,用于管理和发布 Flink 任务,这里讲述一种通过 rest api 来获取 Checkpoint 目录的方式。

undefined

如上图所示是 Flink JobManager 的 overview 页面,只需要将端口号后面的路径和参数按照以下替换即可:

1
http://node107.bigdata.dmp.local.com:35524/jobs/a1c70b36d19b3a9fc2713ba98cfc4a4f/metrics?get=lastCheckpointExternalPath

调用以上接口,即可返回 a1c70b36d19b3a9fc2713ba98cfc4a4f 对应的 job 最后一次 Checkpoint 的目录,返回格式如下所示。

1
2
3
4
5
6
[
{
"id": "lastCheckpointExternalPath",
"value": "hdfs:/user/flink/checkpoints/a1c70b36d19b3a9fc2713ba98cfc4a4f/chk-18"
}
]

通过这种方式可以方便地维护所有 Flink 任务的 Checkpoint 目录,当然也可以通过 Metrics 的 Reporter 将 Checkpoint 目录保存到外部存储介质中,当任务需要从 Checkpoint 处恢复时,则从外部存储中读取到相应的 Checkpoint 目录。

当设置取消 Flink 任务保留外部的 Checkpoint 状态信息时,可能会带来的负面影响是:长期运行下去,hdfs 上将会保留很多废弃的且不再会使用的 Checkpoint 目录,所以如果开启了此配置,需要制定策略,定期清理那些不再会使用到的 Checkpoint 目录。

状态如何从 Savepoint 恢复

如下所示,从 Savepoint 恢复任务的命令与 Checkpoint 恢复命令类似,savepointPath 表示 Savepoint 保存的目录,Savepoint 的各种触发方式都会返回 Savepoint 目录。

1
bin/flink run -s :savepointPath xxx.jar [:runArgs]

如果 flink on yarn 模式,启动命令如下所示:

1
bin/flink run -s :savepointPath -yid :yarnAppId xxx.jar [:runArgs]

默认情况下,恢复操作将尝试将 Savepoint 的所有状态映射到要还原的程序。如果删除了算子,则可以通过 --allowNonRestoredState(short:-n)选项跳过那些无法映射到新程序的状态:

1
bin/flink run -s :savepointPath -n xxx.jar [:runArgs]

如果从 Savepoint 恢复时,在任务中添加一个需要状态的新算子,会发生什么?向任务添加新算子时,它将在没有任何状态的情况下进行初始化,Savepoint 中包含每个有状态算子的状态,无状态算子根本不是 Savepoint 的一部分,新算子的行为类似于无状态算子。

如果在任务中对算子进行重新排序,会发生什么?如果给这些算子分配了 ID,它们将像往常一样恢复。如果没有分配 ID ,则有状态算子自动生成的 ID 很可能在重新排序后发生更改,这将导致无法从之前的 Savepoint 中恢复。

Savepoint 目录里的状态快照信息,目前不支持移动位置,由于技术原因元数据文件中使用绝对路径来保存数据。如果因为某种原因必须要移动 Savepoint 文件,那么有两种方案来实现:

  • 使用编辑器修改 Savepoint 的元数据文件信息,将旧路径改为新路径
  • 可以使用 SavepointV2Serializer 类以编程方式读取、操作和重写元数据文件的新路径

长期使用 Savepoint 同样要注意清理那些废弃 Savepoint 目录的问题。

小结与反思

本节主要介绍了 Checkpoint、Savepoint、Checkpoint 与 Savepoint 之间的区别以及 Checkpoint 和 Savepoint 具体如何使用并从 Checkpoint 和 Savepoint 中恢复任务。在 Checkpoint 过程中有一个同步做快照的过程,同步在快照期间 Flink 不会处理数据,为什么这里不能处理数据呢?如果做快照的同时处理数据会有什么影响呢?

前面的内容都是讲解 DataStream 和 DataSet API 相关的,在 1.2.5 节中讲解 Flink API 时提及到 Flink 的高级 API——Table API&SQL,本节将开始 Table&SQL 之旅。

在 Flink 1.9 版本中,合进了阿里巴巴开源的 Blink 版本中的大量代码,其中最重要的贡献就是 Blink SQL 了。在 Blink 捐献给 Apache Flink 之后,社区就致力于为 Table API&SQL 集成 Blink 的查询优化器和 runtime。先来看下 1.8 版本的 Flink Table 项目结构如下图:

undefined

1.9 版本的 Flink Table 项目结构图如下:

undefined

可以发现新增了 flink-sql-parser、flink-table-planner-blink、flink-table-runtime-blink、flink-table-uber-blink 模块,对 Flink Table 模块的重构详细内容可以参考 FLIP-32。这样对于 Java 和 Scala API 模块、优化器以及 runtime 模块来说,分层更清楚,接口更明确。

另外 flink-table-planner-blink 模块中实现了新的优化器接口,所以现在有两个插件化的查询处理器来执行 Table API&SQL:1.9 以前的 Flink 处理器和新的基于 Blink 的处理器。基于 Blink 的查询处理器提供了更好的 SQL 覆盖率、支持更广泛的查询优化、改进了代码生成机制、通过调优算子的实现来提升批处理查询的性能。除此之外,基于 Blink 的查询处理器还提供了更强大的流处理能力,包括了社区一些非常期待的新功能(如维表 Join、TopN、去重)和聚合场景缓解数据倾斜的优化,以及内置更多常用的函数,具体可以查看 flink-table-runtime-blink 代码。目前整个模块的结构如下:

undefined

注意:两个查询处理器之间的语义和功能大部分是一致的,但未完全对齐,因为基于 Blink 的查询处理器还在优化中,所以在 1.9 版本中默认查询处理器还是 1.9 之前的版本。如果你想使用 Blink 处理器的话,可以在创建 TableEnvironment 时通过 EnvironmentSettings 配置启用。被选择的处理器必须要在正在执行的 Java 进程的类路径中。对于集群设置,默认两个查询处理器都会自动地加载到类路径中。如果要在 IDE 中运行一个查询,需要在项目中添加 planner 依赖。

为什么选择 Table API&SQL?

在 1.2 节中介绍了 Flink 的 API 是包含了 Table API&SQL,在 1.3 节中也介绍了在 Flink 1.9 中阿里开源的 Blink 分支中的很强大的 SQL 功能合并进 Flink 主分支,另外通过阿里 Blink 相关的介绍,可以知道阿里在 SQL 功能这块是做了很多的工作。从前面章节的内容可以发现 Flink 的 DataStream/DataSet API 的功能已经很全并且很强大了,常见复杂的数据处理问题也都可以处理,那么社区为啥还在一直推广 Table API&SQL 呢?

其实通过观察其它的大数据组件,就不会好奇了,比如 Spark、Storm、Beam、Hive 、KSQL(面向 Kafka 的 SQL 引擎)、Elasticsearch、Phoenix(使用 SQL 进行 HBase 数据的查询)等,可以发现 SQL 已经成为各个大数据组件必不可少的数据查询语言,那么 Flink 作为一个大数据实时处理引擎,笔者对其支持 SQL 查询流数据也不足为奇了,但是还是来稍微介绍一下 Table API&SQL。

Table API&SQL 是一种关系型 API,用户可以像操作数据库一样直接操作流数据,而不再需要通过 DataStream API 来写很多代码完成计算需求,更不用手动去调优你写的代码,另外 SQL 最大的优势在于它是一门学习成本很低的语言,普及率很高,用户基数大,和其他的编程语言相比,它的入门相对简单。

除了上面的原因,还有一个原因是:可以借助 Table API&SQL 统一流处理和批处理,因为在 DataStream/DataSet API 中,用户开发流作业和批作业需要去了解两种不同的 API,这对于公司有些开发能力不高的数据分析师来说,学习成本有点高,他们其实更擅长写 SQL 来分析。Table API&SQL 做到了批与流上的查询具有同样的语法语义,因此不用改代码就能同时在批和流上执行。

总结来说,为什么选择 Table API&SQL:

  • 声明式语言表达业务逻辑
  • 无需代码编程——易于上手
  • 查询能够被有效的优化
  • 查询可以高效的执行

在上文中提及到 Flink Table 在 1.8 和 1.9 的区别,这里还是要再讲解一下这几个依赖,因为只有了解清楚了之后,我们在后面开发的时候才能够清楚挑选哪种依赖。它有如下几个模块:

  • flink-table-common:table 中的公共模块,可以用于通过自定义 function,format 等来扩展 Table 生态系统
  • flink-table-api-java:支持使用 Java 语言,纯 Table&SQL API
  • flink-table-api-scala:支持使用 Scala 语言,纯 Table&SQL API
  • flink-table-api-java-bridge:支持使用 Java 语言,包含 DataStream/DataSet API 的 Table&SQL API(推荐使用)
  • flink-table-api-scala-bridge:支持使用 Scala 语言,带有 DataStream/DataSet API 的 Table&SQL API(推荐使用)
  • flink-sql-parser:SQL 语句解析层,主要依赖 calcite
  • flink-table-planner:Table 程序的 planner 和 runtime
  • flink-table-uber:将上诉模块打成一个 fat jar,在 lib 目录下
  • flink-table-planner-blink:Blink 的 Table 程序的 planner(阿里开源的版本)
  • flink-table-runtime-blink:Blink 的 Table 程序的 runtime(阿里开源的版本)
  • flink-table-uber-blink:将 Blink 版本的 planner 和 runtime 与前面模块(除 flink-table-planner 模块)打成一个 fat jar,在 lib 目录下

undefined

  • flink-sql-client:SQL 客户端

两种 planner 之间的区别

上面讲了两种不同的 planner 之间包含的模块有点区别,但是具体有什么区别如下所示:

  • Blink planner 将批处理作业视为流的一种特殊情况。因此不支持 Table 和 DataSet 之间的转换,批处理作业会转换成 DataStream 程序,而不会转换成 DataSet 程序,流作业还是转换成 DataStream 程序。
  • Blink planner 不支持 BatchTableSource,而是使用有界的(bounded) StreamTableSource 代替它。
  • Blink planner 仅支持全新的 Catalog,不支持已经废弃的 ExternalCatalog。
  • 以前的 planner 中 FilterableTableSource 的实现与现在的 Blink planner 有冲突,在以前的 planner 中是叠加 PlannerExpressions(在未来的版本中会移除),而在 Blink planner 中是 Expressions。
  • 基于字符串的 KV 键值配置选项仅可以在 Blink planner 中使用。
  • PlannerConfig 的实现(CalciteConfig)在两种 planner 中不同。
  • Blink planner 会将多个 sink 优化在同一个 DAG 中(只在 TableEnvironment 中支持,StreamTableEnvironment 中不支持),而以前的 planner 是每个 sink 都有一个 DAG 中,相互独立的。
  • 以前的 planner 不支持 catalog 统计,而 Blink planner 支持。

在了解到了两种 planner 的区别后,接下来开始 Flink Table API&SQL 之旅。

添加项目依赖

因为在 Flink 1.9 版本中有两个 planner,所以得根据你使用的 planner 来选择对应的依赖,假设你选择的是最新的 Blink 版本,那么添加下面的依赖:

1
2
3
4
5
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-table-planner-blink_${scala.binary.version}</artifactId>
<version>${flink.version}</version>
</dependency>

如果是以前的 planner,则使用下面这个依赖:

1
2
3
4
5
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-table-planner_${scala.binary.version}</artifactId>
<version>${flink.version}</version>
</dependency>

如果要自定义 format 格式或者自定义 function,则需要添加 flink-table-common 依赖:

1
2
3
4
5
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-table-common</artifactId>
<version>${flink.version}</version>
</dependency>

创建一个 TableEnvironment

TableEnvironment 是 Table API 和 SQL 的统称,它负责的内容有:

  • 在内部的 catalog 注册 Table
  • 注册一个外部的 catalog
  • 执行 SQL 查询
  • 注册用户自定义的 function
  • 将 DataStream 或者 DataSet 转换成 Table
  • 保持对 ExecutionEnvironment 和 StreamExecutionEnvironment 的引用

Table 总是会绑定在一个指定的 TableEnvironment,不能在同一个查询中组合不同 TableEnvironment 的 Table,比如 join 或 union 操作。你可以使用下面的几种静态方法创建 TableEnvironment。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//创建 StreamTableEnvironment
static StreamTableEnvironment create(StreamExecutionEnvironment executionEnvironment) {
return create(executionEnvironment, EnvironmentSettings.newInstance().build());
}

static StreamTableEnvironment create(StreamExecutionEnvironment executionEnvironment, EnvironmentSettings settings) {
return StreamTableEnvironmentImpl.create(executionEnvironment, settings, new TableConfig());
}

/** @deprecated */
@Deprecated
static StreamTableEnvironment create(StreamExecutionEnvironment executionEnvironment, TableConfig tableConfig) {
return StreamTableEnvironmentImpl.create(executionEnvironment, EnvironmentSettings.newInstance().build(), tableConfig);
}

//创建 BatchTableEnvironment
static BatchTableEnvironment create(ExecutionEnvironment executionEnvironment) {
return create(executionEnvironment, new TableConfig());
}

static BatchTableEnvironment create(ExecutionEnvironment executionEnvironment, TableConfig tableConfig) {
//
}

你需要根据你的程序来使用对应的 TableEnvironment,是 BatchTableEnvironment 还是 StreamTableEnvironment。默认两个 planner 都是在 Flink 的安装目录下 lib 文件夹中存在的,所以应该在你的程序中指定使用哪种 planner。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Flink Streaming query
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.table.api.EnvironmentSettings;
import org.apache.flink.table.api.java.StreamTableEnvironment;
EnvironmentSettings fsSettings = EnvironmentSettings.newInstance().useOldPlanner().inStreamingMode().build();
StreamExecutionEnvironment fsEnv = StreamExecutionEnvironment.getExecutionEnvironment();
StreamTableEnvironment fsTableEnv = StreamTableEnvironment.create(fsEnv, fsSettings);
//或者 TableEnvironment fsTableEnv = TableEnvironment.create(fsSettings);

// Flink Batch query
import org.apache.flink.api.java.ExecutionEnvironment;
import org.apache.flink.table.api.java.BatchTableEnvironment;
ExecutionEnvironment fbEnv = ExecutionEnvironment.getExecutionEnvironment();
BatchTableEnvironment fbTableEnv = BatchTableEnvironment.create(fbEnv);

// Blink Streaming query
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.table.api.EnvironmentSettings;
import org.apache.flink.table.api.java.StreamTableEnvironment;
StreamExecutionEnvironment bsEnv = StreamExecutionEnvironment.getExecutionEnvironment();
EnvironmentSettings bsSettings = EnvironmentSettings.newInstance().useBlinkPlanner().inStreamingMode().build();
StreamTableEnvironment bsTableEnv = StreamTableEnvironment.create(bsEnv, bsSettings);
//或者 TableEnvironment bsTableEnv = TableEnvironment.create(bsSettings);

// Blink Batch query
import org.apache.flink.table.api.EnvironmentSettings;
import org.apache.flink.table.api.TableEnvironment;
EnvironmentSettings bbSettings = EnvironmentSettings.newInstance().useBlinkPlanner().inBatchMode().build();
TableEnvironment bbTableEnv = TableEnvironment.create(bbSettings);

如果在 lib 目录下只存在一个 planner,则可以使用 useAnyPlanner 来创建指定的 EnvironmentSettings。

Table API&SQL 应用程序的结构

批处理和流处理的 Table API&SQL 作业都有相同的模式,它们的代码结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//根据前面内容创建一个 TableEnvironment,指定是批作业还是流作业
TableEnvironment tableEnv = ...;

//用下面的其中一种方式注册一个 Table
tableEnv.registerTable("table1", ...)
tableEnv.registerTableSource("table2", ...);
tableEnv.registerExternalCatalog("extCat", ...);

//注册一个 TableSink
tableEnv.registerTableSink("outputTable", ...);

//根据一个 Table API 查询创建一个 Table
Table tapiResult = tableEnv.scan("table1").select(...);
//根据一个 SQL 查询创建一个 Table
Table sqlResult = tableEnv.sqlQuery("SELECT ... FROM table2 ... ");

//将 Table API 或者 SQL 的结果发送给 TableSink
tapiResult.insertInto("outputTable");

//运行
tableEnv.execute("java_job");

Catalog 中注册 Table

Table 有两种类型,输入表和输出表,可以在 Table API&SQL 查询中引用输入表并提供输入数据,输出表可以用于将 Table API&SQL 的查询结果发送到外部系统。输出表可以通过 TableSink 来注册,输入表可以从各种数据源进行注册:

  • 已经存在的 Table 对象,通过是 Table API 或 SQL 查询的结果
  • 连接了外部系统的 TableSource,比如文件、数据库、MQ
  • 从 DataStream 或 DataSet 程序中返回的 DataStream 和 DataSet

注册 Table

在 TableEnvironment 中可以像下面这样注册一个 Table:

1
2
3
4
5
6
7
8
//创建一个 TableEnvironment
TableEnvironment tableEnv = ...; // see "Create a TableEnvironment" section

//projTable 是一个简单查询的结果
Table projTable = tableEnv.scan("X").select(...);

//将 projTable 表注册为 projectedTable 表
tableEnv.registerTable("projectedTable", projTable);

注册 TableSource

TableSource 让你可以访问存储系统(数据库 MySQL、HBase 等)、编码文件(CSV、Parquet、Avro 等)或 MQ(Kafka、RabbitMQ) 中的数据。Flink 为常用组件都提供了 TableSource,另外还提供自定义 TableSource。在 TableEnvironment 中可以像下面这样注册 TableSource:

1
2
3
4
5
6
7
TableEnvironment tableEnv = ...;

//创建 TableSource
TableSource csvSource = new CsvTableSource("/Users/zhisheng/file", ...);

//将 csvSource 注册为表
tableEnv.registerTableSource("CsvTable", csvSource);

注意:用于 Blink planner 的 TableEnvironment 只能接受 StreamTableSource、LookupableTableSource 和 InputFormatTableSource,用于 Blink planner 批处理的 StreamTableSource 必须是有界的。

注册 TableSink

TableSink 可以将 Table API&SQL 查询的结果发送到外部的存储系统去,比如数据库、KV 存储、文件(CSV、Parquet 等)或 MQ 等。Flink 为常用等数据存储系统和文件格式都提供了 TableSink,另外还支持自定义 TableSink。在 TableEnvironment 中可以像下面这样注册 TableSink:

1
2
3
4
5
6
7
8
9
10
11
TableEnvironment tableEnv = ...;

//创建 TableSink
TableSink csvSink = new CsvTableSink("/Users/zhisheng/file", ...);

//定义属性名和类型
String[] fieldNames = {"a", "b", "c"};
TypeInformation[] fieldTypes = {Types.INT, Types.STRING, Types.LONG};

//将 csvSink 注册为表 CsvSinkTable
tableEnv.registerTableSink("CsvSinkTable", fieldNames, fieldTypes, csvSink);

注册外部的 Catalog

外部的 Catalog 可以提供外部的数据库和表的信息,例如它们的名称、schema、统计信息以及如何访问存储在外部数据库、表、文件中的数据。可以通过实现 ExternalCatalog 接口来创建外部的 Catalog,并像下面这样注册外部的 Catalog:

1
2
3
4
5
6
7
8
9
10
TableEnvironment tableEnv = ...;

//创建外部的 catalog
ExternalCatalog catalog = new InMemoryExternalCatalog();
//注册 ExternalCatalog
tableEnv.registerExternalCatalog("InMemCatalog", catalog);//该方法已经标记过期,可以使用 Catalog

//使用下面这种
Catalog catalog = new GenericInMemoryCatalog("zhisheng");
tableEnv.registerCatalog("InMemCatalog", catalog);

在注册后,ExternalCatalog 中的表数据信息可以通过 Table API&SQL 查询获取到。Flink 提供了 Catalog 的一种实现类 GenericInMemoryCatalog 用于样例和测试。

查询 Table

Table API

先来演示使用 Table API 来完成一个简单聚合查询:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
TableEnvironment tableEnv = ...;

//注册 Orders 表

//查询注册的 Orders 表
Table orders = tableEnv.scan("Orders");
//计算来自中国的顾客的收入
Table revenue = orders
.filter("cCountry === 'China'")
.groupBy("cID, cName")
.select("cID, cName, revenue.sum AS revSum");

//转换或者提交该结果表
//运行该查询语句

你可以使用 Java 或者 Scala 语言来利用 Table API 开发,而 SQL 却不是这样的。

SQL

上面使用 Table API 的聚合查询样例使用 SQL 来完成就如下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
TableEnvironment tableEnv = ...;

//注册 Orders 表

//计算来自中国的顾客的收入
Table revenue = tableEnv.sqlQuery(
"SELECT cID, cName, SUM(revenue) AS revSum " +
"FROM Orders " +
"WHERE cCountry = 'FRANCE' " +
"GROUP BY cID, cName"
);

//转换或者提交该结果表
//运行该查询语句

Flink 的 SQL 是基于实现 SQL 标准的 Apache Calcite,SQL 的查询语句就是全部为字符串,上面这条 SQL 就说明了该如何指定查询并返回结果表,下面演示如何更新。

1
2
3
4
5
6
7
tableEnv.sqlUpdate(
"INSERT INTO RevenueFrance " +
"SELECT cID, cName, SUM(revenue) AS revSum " +
"FROM Orders " +
"WHERE cCountry = 'FRANCE' " +
"GROUP BY cID, cName"
);

Table API&SQL

Table API 和 SQL 之间可以相互结合,因为它们最后都是返回的 Table 对象,比如你可以在 SQL 查询返回的对象上定义 Table API 的查询,也可以在 Table API 查询结果返回的对象上定义 SQL 查询。

提交 Table

在前面讲解了注册 TableSink,那么将表的结果提交就是将 Table 写入 TableSink,批处理的 Table 只能写入到 BatchTableSink,而流处理的 Table 可以写入进 AppendStreamTableSink、RetractStreamTableSink、UpsertStreamTableSink。使用 Table.insertInto(String tableName) 方法就可以将 Table 写入进已注册的 TableSink,它会根据名字去 catalog 中查找,并对比两者的 schema 是否相同。

翻译并执行查询

对于两种不同的 planner,翻译和执行查询的行为是不同的。

  • 之前的 planner:根据 Table API&SQL 查询的输入是流还是批,然后先优化执行计划,接着对应转换成 DataStream 和 DataSet 程序,当 Table.insertInto() 和 TableEnvironment.sqlUpdate() 方法被调用、Table 转换成 DataStream 或 DataSet 时就会开始将 Table API 和 SQL 进行翻译,一旦翻译翻译完成后,也是和普通作业一样要执行 execute 方法后才开始运行。
  • Blink planner:不管 Table API 的输入是批还是流,都会转换成 DataStream 程序,对于 TableEnvironment 和 StreamTableEnvironment 的查询翻译是不一样的,对于 TableEnvironment,是在 TableEnvironment.execute() 调用的时候就会翻译 Table API&SQL,因为 TableEnvironment 会将多个 Sink 优化在同一个 DAG 中,而 StreamTableEnvironment 和之前的 planner 是类似的。

小结与反思

本节介绍了 Flink 新的 planner,然后详细地和之前的 planner 做了对比,然后对 Table API&SQL 中的概念做了介绍,还通过样例去介绍了它们的通用 API。

二十四、Flink Table API & SQL 功能

在 5.1 节中对 Flink Table API & SQL 的概述和常见 API 都做了介绍,这篇文章先来看下其与 DataStream 和 DataSet API 的集成。

两个 planner 都可以与 DataStream API 集成,只有以前的 planner 才可以集成 DataSet API,所以下面讨论 DataSet API 都是和以前的 planner 有关。

Table API & SQL 查询与 DataStream 和 DataSet 程序集成是非常简单的,比如可以通过 Table API 或者 SQL 查询外部表数据,进行一些预处理后,然后使用 DataStream 或 DataSet API 继续处理一些复杂的计算,另外也可以将 DataStream 或 DataSet 处理后的数据利用 Table API 或者 SQL 写入到外部表去。总而言之,它们之间互相转换或者集成比较容易。

Scala 的隐式转换

Scala Table API 提供了 DataSet、DataStream 和 Table 类的隐式转换,可以通过导入 org.apache.flink.table.api.scala._ 或者 org.apache.flink.api.scala._ 包来启用这些转换。

将 DataStream 或 DataSet 注册为 Table

DataStream 或者 DataSet 可以注册为 Table,结果表的 schema 取决于已经注册的 DataStream 和 DataSet 的数据类型。你可以像下面这种方式转换:

1
2
3
4
5
6
7
8
9
StreamTableEnvironment tableEnv = ...;

DataStream<Tuple2<Long, String>> stream = ...

//将 DataStream 注册为 myTable 表
tableEnv.registerDataStream("myTable", stream);

//将 DataStream 注册为 myTable2 表(表中的字段为 myLong、myString)
tableEnv.registerDataStream("myTable2", stream, "myLong, myString");

将 DataStream 或 DataSet 转换为 Table

除了可以将 DataStream 或 DataSet 注册为 Table,还可以将它们转换为 Table,转换之后再去使用 Table API 查询就比较方便了。

1
2
3
4
5
6
7
8
9
StreamTableEnvironment tableEnv = ...;

DataStream<Tuple2<Long, String>> stream = ...

//将 DataStream 转换成 Table
Table table1 = tableEnv.fromDataStream(stream);

//将 DataStream 转换成 Table
Table table2 = tableEnv.fromDataStream(stream, "myLong, myString");

将 Table 转换成 DataStream 或 DataSet

Table 可以转换为 DataStream 或 DataSet,这样就可以在 Table API 或 SQL 查询的结果上运行自定义的 DataStream 或 DataSet 程序。当将一个 Table 转换成 DataStream 或 DataSet 时,需要指定结果 DataStream 或 DataSet 的数据类型,最方便的数据类型是 Row,下面几个数据类型表示不同的功能:

  • Row:字段按位置映射,任意数量的字段,支持 null 值,没有类型安全访问。
  • POJO:字段按名称映射,POJO 属性必须按照 Table 中的属性来命名,任意数量的字段,支持 null 值,类型安全访问。
  • Case Class:字段按位置映射,不支持 null 值,类型安全访问。
  • Tuple:按位置映射字段,限制为 22(Scala)或 25(Java)字段,不支持 null 值,类型安全访问。
  • 原子类型:Table 必须具有单个字段,不支持 null 值,类型安全访问。
将 Table 转换成 DataStream

流查询的结果表会动态更新,即每个新的记录到达输入流时结果就会发生变化。所以在将 Table 转换成 DataStream 就需要对表的更新进行编码,有两种将 Table 转换为 DataStream 的模式:

  • 追加模式(Append Mode):这种模式只能在动态表仅通过 INSERT 更改修改时才能使用,即仅追加,之前发出的结果不会更新。
  • 撤回模式(Retract Mode):任何时刻都可以使用此模式,它使用一个 boolean 标志来编码 INSERT 和 DELETE 的更改。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
StreamTableEnvironment tableEnv = ...;

//有两个字段(name、age) 的 Table
Table table = ...

//通过指定类,将表转换为一个 append DataStream
DataStream<Row> dsRow = tableEnv.toAppendStream(table, Row.class);

//将表转换为 Tuple2<String, Integer> 的 append DataStream
TupleTypeInfo<Tuple2<String, Integer>> tupleType = new TupleTypeInfo<>(Types.STRING(), Types.INT());
DataStream<Tuple2<String, Integer>> dsTuple = tableEnv.toAppendStream(table, tupleType);

//将表转换为一个 Retract DataStream Row
DataStream<Tuple2<Boolean, Row>> retractStream = tableEnv.toRetractStream(table, Row.class);
将 Table 转换成 DataSet

将 Table 转换成 DataSet 的样例如下:

1
2
3
4
5
6
7
8
9
10
11
BatchTableEnvironment tableEnv = BatchTableEnvironment.create(env);

//有两个字段(name、age) 的 Table
Table table = ...

//通过指定一个类将表转换为一个 Row DataSet
DataSet<Row> dsRow = tableEnv.toDataSet(table, Row.class);

//将表转换为 Tuple2<String, Integer> 的 DataSet
TupleTypeInfo<Tuple2<String, Integer>> tupleType = new TupleTypeInfo<>(Types.STRING(), Types.INT());
DataSet<Tuple2<String, Integer>> dsTuple = tableEnv.toDataSet(table, tupleType);

查询优化

Flink 使用 Calcite 来优化和翻译查询,以前的 planner 不会去优化 join 的顺序,而是按照查询中定义的顺序去执行。通过提供一个 CalciteConfig 对象来调整在不同阶段应用的优化规则集,这个可以通过调用 CalciteConfig.createBuilder() 获得的 builder 来创建,并且可以通过调用tableEnv.getConfig.setCalciteConfig(calciteConfig) 来提供给 TableEnvironment。而在 Blink planner 中扩展了 Calcite 来执行复杂的查询优化,这包括一系列基于规则和成本的优化,比如:

  • 基于 Calcite 的子查询去相关性
  • Project pruning
  • Partition pruning
  • Filter push-down
  • 删除子计划中的重复数据以避免重复计算
  • 重写特殊的子查询,包括两部分:
    • 将 IN 和 EXISTS 转换为 left semi-joins
    • 将 NOT IN 和 NOT EXISTS 转换为 left anti-join
  • 重排序可选的 join
    • 通过启用 table.optimizer.join-reorder-enabled

注意:IN/EXISTS/NOT IN/NOT EXISTS 目前只支持子查询重写中的连接条件。

解释 Table

Table API 提供了一种机制来解释计算 Table 的逻辑和优化查询计划。你可以通过 TableEnvironment.explain(table) 或者 TableEnvironment.explain() 方法来完成。explain(table) 会返回给定计划的 Table,explain() 会返回多路 Sink 计划的结果(主要用于 Blink planner)。它返回一个描述三个计划的字符串:

  • 关系查询的抽象语法树,即未优化的逻辑查询计划
  • 优化的逻辑查询计划
  • 实际执行计划

以下代码演示了一个 Table 示例:

1
2
3
4
5
6
7
8
9
10
11
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
StreamTableEnvironment tEnv = StreamTableEnvironment.create(env);

DataStream<Tuple2<Integer, String>> stream1 = env.fromElements(new Tuple2<>(1, "hello"));
DataStream<Tuple2<Integer, String>> stream2 = env.fromElements(new Tuple2<>(1, "hello"));

Table table1 = tEnv.fromDataStream(stream1, "count, word");
Table table2 = tEnv.fromDataStream(stream2, "count, word");
Table table = table1.where("LIKE(word, 'F%')").unionAll(table2);

System.out.println(tEnv.explain(table));

通过 explain(table) 方法返回的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
== Abstract Syntax Tree ==
LogicalUnion(all=[true])
LogicalFilter(condition=[LIKE($1, _UTF-16LE'F%')])
FlinkLogicalDataStreamScan(id=[1], fields=[count, word])
FlinkLogicalDataStreamScan(id=[2], fields=[count, word])

== Optimized Logical Plan ==
DataStreamUnion(all=[true], union all=[count, word])
DataStreamCalc(select=[count, word], where=[LIKE(word, _UTF-16LE'F%')])
DataStreamScan(id=[1], fields=[count, word])
DataStreamScan(id=[2], fields=[count, word])

== Physical Execution Plan ==
Stage 1 : Data Source
content : collect elements with CollectionInputFormat

Stage 2 : Data Source
content : collect elements with CollectionInputFormat

Stage 3 : Operator
content : from: (count, word)
ship_strategy : REBALANCE

Stage 4 : Operator
content : where: (LIKE(word, _UTF-16LE'F%')), select: (count, word)
ship_strategy : FORWARD

Stage 5 : Operator
content : from: (count, word)
ship_strategy : REBALANCE

数据类型

在 Flink 1.9 之前,Flink 的 Table API&SQL 的数据类型与 Flink 中的 TypeInformation 紧密相关。TypeInformation 在 DataStream 和 DataSet API 中使用,另外它还可以描述在分布式中序列化和反序列化基于 JVM 对象所需的所有信息。从 1.9 版本之后,Table API&SQL 会引入一种新类型来作为 API 稳定性和标准的长期解决方案。在以前的 planner 和 Blink planner 的数据类型有点不一致,具体差别可以参考官网。

时间属性

在 3.1 节中介绍过 Flink 的多种时间语义,常用的比如 Event time 和 Processing time,那么在 Table API&SQL 中怎么去定义时间语义呢?

Processing Time

因为处理时间是额外的数据字段,在原始的事件中是不存在该字段的,那么在将数据流转换成 Table 的时候就需要将这个 Processing time 当作 Table 的一个字段,以供后面需要,比如定义窗口。你可以像下面这样定义:

1
2
3
4
5
6
DataStream<Tuple2<String, String>> stream = ...;

//将附加的逻辑字段声明为 Processing time 属性
Table table = tEnv.fromDataStream(stream, "Username, Data, UserActionTime.proctime");

WindowedTable windowedTable = table.window(Tumble.over("10.minutes").on("UserActionTime").as("userActionWindow"));

如果是直接使用 TableSource 的话,那么需要实现 DefinedProctimeAttribute 接口,然后去重写 getProctimeAttribute 方法,返回的字符串表示 Processing time 在 Table 中的字段名。

Event time

Event time 是在采集上来的事件中就有的,将数据流转换成 Table 的时候需要像下面这样定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
//第一种方法:
//提取流数据时间戳并分配水印
DataStream<Tuple2<String, String>> stream = inputStream.assignTimestampsAndWatermarks(...);
//将附加的逻辑字段声明为 Event time 属性,和 Processing time 不同的是这里使用 rowtime
Table table = tEnv.fromDataStream(stream, "Username, Data, UserActionTime.rowtime");

//第二种方法:
//从第一个字段提取时间戳,并分配水印
DataStream<Tuple3<Long, String, String>> stream = inputStream.assignTimestampsAndWatermarks(...);
Table table = tEnv.fromDataStream(stream, "UserActionTime.rowtime, Username, Data");

//使用方式:
WindowedTable windowedTable = table.window(Tumble.over("10.minutes").on("UserActionTime").as("userActionWindow"));

使用 TableSource 的话则需要实现 DefinedRowtimeAttributes 接口,重写 getRowtimeAttributeDescriptors 方法,该方法返回一个 RowtimeAttributeDescriptor 列表,其用于描述时间属性的最终名称、时间提取器以及该属性关联的水印策略。

SQL Connector

在第三部分中介绍了大量的 Flink Connectors 的使用,但是那些都是通过 DataStream API 是去使用,放在 Table API&SQL 中其实不再适合,其实 Flink Table API&SQL 是可以直接连接到外部系统的,然后读取和写入批处理表和流处理表。TableSource 提供从外部系统(数据库、MQ、文件系统等)读取数据,TableSink 将结果存储到数据库中。这里讲解一下该如何去定义 TableSource 和 TableSink 并将它们注册。在官网,它提供了如下这些 Connectors 和 Formats 的下载。

undefined

从 Flink 1.6 开始,不仅可以使用编程的方式指定 Connector,还可以使用声明式去定义。下面举个例子(读取 Kafka 中 Avro 格式的数据)来讲解这两种区别。

使用代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
tableEnvironment
//声明要连接的外部系统
.connect(
new Kafka()
.version("0.10")
.topic("zhisheng_user")
.startFromEarliest()
.property("zookeeper.connect", "localhost:2181")
.property("bootstrap.servers", "localhost:9092")
)
//定义数据格式
.withFormat(
new Avro()
.avroSchema(
"{" +
" \"namespace\": \"com.zhisheng\"," +
" \"type\": \"record\"," +
" \"name\": \"UserMessage\"," +
" \"fields\": [" +
" {\"name\": \"timestamp\", \"type\": \"string\"}," +
" {\"name\": \"user\", \"type\": \"long\"}," +
" {\"name\": \"message\", \"type\": [\"string\", \"null\"]}" +
" ]" +
"}"
)
)
//定义 Table schema
.withSchema(
new Schema()
.field("rowtime", Types.SQL_TIMESTAMP)
.rowtime(new Rowtime()
.timestampsFromField("timestamp")
.watermarksPeriodicBounded(60000)
)
.field("user", Types.LONG)
.field("message", Types.STRING)
)
.inAppendMode() //指定流表的 update-mode
.registerTableSource("zhisheng"); //注册表的名字

使用 YAML 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
tables:
- name: zhisheng #表的名字
type: source #定义是 source,还是 sink,或者 both
update-mode: append #指定流表的 update-mode
#定义要连接的系统
connector:
type: kafka
version: "0.10"
topic: zhisheng_user
startup-mode: earliest-offset
properties:
- key: zookeeper.connect
value: localhost:2181
- key: bootstrap.servers
value: localhost:9092

#定义格式
format:
type: avro
avro-schema: >
{
"namespace": "com.zhisheng",
"type": "record",
"name": "UserMessage",
"fields": [
{"name": "ts", "type": "string"},
{"name": "user", "type": "long"},
{"name": "message", "type": ["string", "null"]}
]
}
#定义 table schema
schema:
- name: rowtime
type: TIMESTAMP
rowtime:
timestamps:
type: from-field
from: ts
watermarks:
type: periodic-bounded
delay: "60000"
- name: user
type: BIGINT
- name: message
type: VARCHAR

使用 DDL

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
CREATE TABLE zhisheng (
`user` BIGINT,
message VARCHAR,
ts VARCHAR
) WITH (
'connector.type' = 'kafka',
'connector.version' = '0.10',
'connector.topic' = 'zhisheng_user',
'connector.startup-mode' = 'earliest-offset',
'connector.properties.0.key' = 'zookeeper.connect',
'connector.properties.0.value' = 'localhost:2181',
'connector.properties.1.key' = 'bootstrap.servers',
'connector.properties.1.value' = 'localhost:9092',
'update-mode' = 'append',
'format.type' = 'avro',
'format.avro-schema' = '{
"namespace": "com.zhisheng",
"type": "record",
"name": "UserMessage",
"fields": [
{"name": "ts", "type": "string"},
{"name": "user", "type": "long"},
{"name": "message", "type": ["string", "null"]}
]
}'
)

上面演示了 Kafka Connector 和 avro 数据格式化在 Table API&SQL 中的使用方式,在官网中还有文件系统和 Elasticsearch Connector、CSV 和 JSON 等的使用说明。

SQL Client

虽然 Flink Table API&SQL 让使用 SQL 去查询流数据有了可能,但是这些查询语句通常要嵌入在 Java 或者 Scala 程序中,最后在提交到集群运行之前还要通过构建工具打包,这就导致 Table API&SQL 的限制性很大,所以 SQL Client 就起到这么个作用,让用户不再编写任何 Java 或者 Scala 代码,直接编写 SQL 就可以去调试运行,并且可以通过其他命令行实时查看运行的结果,但是该功能目前还比较弱。

在启动 Flink 后可以通过运行 ./bin/sql-client.sh embedded 命令来启动 SQL Client CLI,如下图所示:

undefined

你可以运行下面的命令就可以知道名字和其出现的次数的结果。

1
SELECT name, COUNT(*) AS cnt FROM (VALUES ('Bob'), ('Alice'), ('Greg'), ('Bob')) AS NameTable(name) GROUP BY name;

另外它还支持传入 YAML 文件,你可以在 YAML 文件中如前面内容一样定义的 Kafka Connector 等信息,关于 SQL Client 的更多功能可以查阅官网。

Hive

Hive 是建立在 Hadoop 上的数据仓库基础构架,它提供了一系列的工具,可以用来进行数据提取转化加载(ETL),这是一种可以存储、查询和分析存储在 Hadoop 中的大规模数据的机制。Hive 定义了简单的类 SQL 查询语言,称为 HQL,它允许熟悉 SQL 的用户查询数据。

Flink 在 1.9 版本中提供了与 Hive 的双重集成。首先是利用 Hive 的 Metastore 存储 Flink 特定元数据,另一个是 Flink 支持读取和写入 Hive 表。支持的 Hive 2.3.4 和 1.2.1 版本,如果你要使用的话,注意它们的依赖是有点不一样。

你可以通过 Java、Scala、YAML 连接 Hive,比如使用 Java 代码如下:

1
2
3
4
5
6
7
String name            = "myhive";
String defaultDatabase = "mydatabase";
String hiveConfDir = "/opt/hive-conf";
String version = "2.3.4"; //或者 1.2.1

HiveCatalog hive = new HiveCatalog(name, defaultDatabase, hiveConfDir, version);
tableEnv.registerCatalog("myhive", hive);

小结与反思

本节继续介绍了 Flink Table API&SQL 中的部分 API,然后讲解了 Flink 之前的 planner 和 Blink planner 在某些特性上面的区别,还讲解了 SQL Connector,最后介绍了 SQL Client 和 Hive。

CEP 是什么?

CEP 的英文全称是 Complex Event Processing,翻译成中文为复杂事件处理。它可以用于处理实时数据并在事件流到达时从事件流中提取信息,并根据定义的规则来判断事件是否匹配,如果匹配则会触发新的事件做出响应。除了支持单个事件的简单无状态的模式匹配(例如基于事件中的某个字段进行筛选过滤),也可以支持基于关联/聚合/时间窗口等多个事件的复杂有状态模式的匹配(例如判断用户下单事件后 30 分钟内是否有支付事件)。

因为这种事件匹配通常是根据提前制定好的规则去匹配的,而这些规则一般来说不仅多,而且复杂,所以就会引入一些规则引擎来处理这种复杂事件匹配。市面上常用的规则引擎有如下这些。

规则引擎对比

Drools

Drools 是一款使用 Java 编写的开源规则引擎,通常用来解决业务代码与业务规则的分离,它内置的 Drools Fusion 模块也提供 CEP 的功能。

优势:

  • 功能较为完善,具有如系统监控、操作平台等功能。
  • 规则支持动态更新。

劣势:

  • 以内存实现时间窗功能,无法支持较长跨度的时间窗。
  • 无法有效支持定时触达(如用户在浏览发生一段时间后触达条件判断)。

Aviator

Aviator 是一个高性能、轻量级的 Java 语言实现的表达式求值引擎,主要用于各种表达式的动态求值。

优势:

  • 支持大部分运算操作符。
  • 支持函数调用和自定义函数。
  • 支持正则表达式匹配。
  • 支持传入变量并且性能优秀。

劣势:

  • 没有 if else、do while 等语句,没有赋值语句,没有位运算符。

EasyRules

EasyRules 集成了 MVEL 和 SpEL 表达式的一款轻量级规则引擎。

优势:

  • 轻量级框架,学习成本低。
  • 基于 POJO。
  • 为定义业务引擎提供有用的抽象和简便的应用
  • 支持从简单的规则组建成复杂规则

Esper

Esper 设计目标为 CEP 的轻量级解决方案,可以方便的嵌入服务中,提供 CEP 功能。

优势:

  • 轻量级可嵌入开发,常用的 CEP 功能简单好用。
  • EPL 语法与 SQL 类似,学习成本较低。

劣势:

  • 单机全内存方案,需要整合其他分布式和存储。
  • 以内存实现时间窗功能,无法支持较长跨度的时间窗。
  • 无法有效支持定时触达(如用户在浏览发生一段时间后触达条件判断)。

Flink 是一个流式系统,具有高吞吐低延迟的特点,Flink CEP 是一套极具通用性、易于使用的实时流式事件处理方案。

优势:

  • 继承了 Flink 高吞吐的特点
  • 事件支持存储到外部,可以支持较长跨度的时间窗。
  • 可以支持定时触达(用 followedBy + PartternTimeoutFunction 实现)

劣势:

  • 无法动态更新规则(痛点)

前面介绍规则引擎的时候,对 Flink CEP 做了一个简单的介绍,因为搭配了 Flink 实时处理的能力,所以 Flink CEP 能够在流处理的场景去做一些实时的复杂事件匹配,它与传统的数据库查询是不一致的,比如,传统的数据库的数据是静态的,但是查询却是动态的,所以传统的数据库查询做不到实时的反馈查询结果,而 Flink CEP 则是查询规则是静态的,数据是动态实时的,如果它作用于一个无限的数据流上,这就意味着它可以将某种规则的数据匹配一直保持下去(除非作业停止);另外 Flink CEP 不需要去存储那些与匹配不相关联的数据,遇到这种数据它会立即丢弃。

虽然 Flink CEP 拥有 Flink 的本身优点和支持复杂场景的规则处理,但是它本身其实也有非常严重的缺点,那就是不能够动态的更新规则。通常引入规则引擎比较友好的一点是可以将一些业务规则抽象出来成为配置,然后更改这些配置后其实是能够自动生效的,但是在 Flink 中却无法做到这点,甚至规则通常还是要写死在代码里面。举个例子,你在一个 Flink CEP 的作业中定义了一条规则:机器的 CPU 使用率连续 30 秒超过 90% 则发出告警,然后将这个作业上线,上线后发现告警很频繁,你可能会觉得可能规则之前定义的不合适,那么接下来你要做的就是将作业取消,然后重新修改代码并进行编译打包成一个 fat jar,接着上传该 jar 并运行。整个流程下来,你有没有想过会消耗多长的时间?五分钟?或者更长?但是你的目的就是要修改一个配置,如果你在作业中将上面的 30 秒和 90% 做成了配置,可能这样所需要的时间会减少,你只需要重启作业,然后通过传入新的参数将作业重新启动,但是重启作业这步是不是不能少,然而对于流作业来说,重启作业带来的代价很大。

针对 Flink CEP 不能动态更新规则的问题,笔者看社区是暂时没有提出解决方案,但是在国内的 Flink 技术分享会却看到有几家公司对这块做了优化,让 Flink CEP 支持动态的更新规则,下面分享一下他们几家公司的思路。

  • A 公司:用户更新规则后,新规则会被翻译成 Java 代码,并编译打包成可执行 jar,停止作业并使用 Savepoint 将状态保存下来,启动新的作业并读取之前保存的状态,会根据规则文件中的数量和复杂度对作业的数量做一个规划,防止单作业负载过高,架构如下图所示。

TIM截图20200409001740.png

B 公司:规则中心存储规则,规则里面直接存储了 Java 代码,加载这些规则后然后再用 Groovy 做动态编译解析,其架构如下图所示。

2019-10-28-143822.png

C 公司:增加函数,在函数方法中监听规则的变化,如果需要更新则通过 Groovy 加载 Pattern 类进行动态注入,采用 Zookeeper 和 MySQL 管理规则,如果规则发生变化,则从数据库中获取到新的规则,然后更新 Flink CEP 中的 NFA 逻辑,注意状态要根据业务需要选择是否重置,其架构设计如下图所示。

2019-10-28-143822.png

第一种方法,笔者不推荐,因为它这样的做法还是要将作业重启,无非就是做了一个自动化的操作,不是人为的手动重启,从 B 公司和 C 公司两种方法可以发现要实现 Flink CEP 动态的更新规则无非要做的就是:

  • 监听规则的变化
  • 将规则变成 Java 代码
  • 通过 Groovy 动态编译解析
  • 更改 NFA 的内部逻辑
  • 状态是否保留

上面虽然提到了一个 Flink CEP 的痛点,但是并不能就此把它的优势给抹去,它可以运用的场景其实还有很多,这里笔者拿某些场景来做个分析。

实时反作弊和风控

对于电商来说,羊毛党是必不可少的,国内拼多多曾爆出 100 元的无门槛券随便领,当晚被人褥几百亿,对于这种情况肯定是没有做好及时的风控。另外还有就是商家上架商品时通过频繁修改商品的名称和滥用标题来提高搜索关键字的排名、批量注册一批机器账号快速刷单来提高商品的销售量等作弊行为,各种各样的作弊手法也是需要不断的去制定规则去匹配这种行为。

实时营销

分析用户在手机 APP 的实时行为,统计用户的活动周期,通过为用户画像来给用户进行推荐。比如用户在登录 APP 后 1 分钟内只浏览了商品没有下单;用户在浏览一个商品后,3 分钟内又去查看其他同类的商品,进行比价行为;用户商品下单后 1 分钟内是否支付了该订单。如果这些数据都可以很好的利用起来,那么就可以给用户推荐浏览过的类似商品,这样可以大大提高购买率。

实时网络攻击检测

当下互联网安全形势仍然严峻,网络攻击屡见不鲜且花样众多,这里我们以 DDOS(分布式拒绝服务攻击)产生的流入流量来作为遭受攻击的判断依据。对网络遭受的潜在攻击进行实时检测并给出预警,云服务厂商的多个数据中心会定时向监控中心上报其瞬时流量,如果流量在预设的正常范围内则认为是正常现象,不做任何操作;如果某数据中心在 10 秒内连续 5 次上报的流量超过正常范围的阈值,则触发一条警告的事件;如果某数据中心 30 秒内连续出现 30 次上报的流量超过正常范围的阈值,则触发严重的告警。

小结与反思

本节介绍了 CEP,并对比了市面上已有的规则引擎,然后介绍了 Flink CEP 的优点和痛点,然后讲解了国内公司对于这个痛点的解决方案,最后讲解了 Flink CEP 的使用场景分析。你们公司有使用 Flink CEP 吗?是什么场景下使用了?

6.1 节中介绍 Flink CEP 和其使用场景,本节将详细介绍 Flink CEP 的 API,教会大家如何去使用 Flink CEP。

准备依赖

要开发 Flink CEP 应用程序,首先你得在项目的 pom.xml 中添加依赖。

1
2
3
4
5
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-cep_${scala.binary.version}</artifactId>
<version>${flink.version}</version>
</dependency>

这个依赖有两种,一个是 Java 版本的,一个是 Scala 版本,你可以根据项目的开发语言自行选择。

准备好依赖后,我们开始第一个 Flink CEP 应用程序,这里我们只做一个简单的数据流匹配,当匹配成功后将匹配的两条数据打印出来。首先定义实体类 Event 如下:

1
2
3
4
public class Event {
private Integer id;
private String name;
}

然后构造读取 Socket 数据流将数据进行转换成 Event,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
SingleOutputStreamOperator<Event> eventDataStream = env.socketTextStream("127.0.0.1", 9200)
.flatMap(new FlatMapFunction<String, Event>() {
@Override
public void flatMap(String s, Collector<Event> collector) throws Exception {
if (StringUtil.isNotEmpty(s)) {
String[] split = s.split(",");
if (split.length == 2) {
collector.collect(new Event(Integer.valueOf(split[0]), split[1]));
}
}
}
});

接着就是定义 CEP 中的匹配规则了,下面的规则表示第一个事件的 id 为 42,紧接着的第二个事件 id 要大于 10,满足这样的连续两个事件才会将这两条数据进行打印出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Pattern<Event, ?> pattern = Pattern.<Event>begin("start").where(
new SimpleCondition<Event>() {
@Override
public boolean filter(Event event) {
log.info("start {}", event.getId());
return event.getId() == 42;
}
}
).next("middle").where(
new SimpleCondition<Event>() {
@Override
public boolean filter(Event event) {
log.info("middle {}", event.getId());
return event.getId() >= 10;
}
}
);

CEP.pattern(eventDataStream, pattern).select(new PatternSelectFunction<Event, String>() {
@Override
public String select(Map<String, List<Event>> p) throws Exception {
StringBuilder builder = new StringBuilder();
log.info("p = {}", p);
builder.append(p.get("start").get(0).getId()).append(",").append(p.get("start").get(0).getName()).append("\n")
.append(p.get("middle").get(0).getId()).append(",").append(p.get("middle").get(0).getName());
return builder.toString();
}
}).print();//打印结果

然后笔者在终端开启 Socket,输入的两条数据如下:

1
2
42,zhisheng
20,zhisheng

作业打印出来的日志如下图:

undefined

整个作业 print 出来的结果如下图:

undefined

好了,一个完整的 Flink CEP 应用程序如上,相信你也能大概理解上面的代码,接着来详细的讲解一下 Flink CEP 中的 Pattern API。

Pattern API

你可以通过 Pattern API 去定义从流数据中匹配事件的 Pattern,每个复杂 Pattern 都是由多个简单的 Pattern 组成的,拿前面入门的应用来讲,它就是由 startmiddle 两个简单的 Pattern 组成的,在其每个 Pattern 中都只是简单的处理了流数据。在处理的过程中需要标示该 Pattern 的名称,以便后续可以使用该名称来获取匹配到的数据,如 p.get("start").get(0) 它就可以获取到 Pattern 中匹配的第一个事件。接下来我们先来看下简单的 Pattern 。

单个 Pattern

数量

单个 Pattern 后追加的 Pattern 如果都是相同的,那如果要都重新再写一遍,换做任何人都会比较痛苦,所以就提供了 times(n) 来表示期望出现的次数,该 times() 方法还有很多写法,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
 //期望符合的事件出现 4 次
start.times(4);

//期望符合的事件不出现或者出现 4 次
start.times(4).optional();

//期望符合的事件出现 2 次或者 3 次或者 4 次
start.times(2, 4);

//期望出现 2 次、3 次或 4 次,并尽可能多地重复
start.times(2, 4).greedy();

//期望出现 2 次、3 次、4 次或者不出现
start.times(2, 4).optional();

//期望出现 0、2、3 或 4 次并尽可能多地重复
start.times(2, 4).optional().greedy();

//期望出现一个或多个事件
start.oneOrMore();

//期望出现一个或多个事件,并尽可能多地重复这些事件
start.oneOrMore().greedy();

//期望出现一个或多个事件或者不出现
start.oneOrMore().optional();

//期望出现更多次,并尽可能多地重复或者不出现
start.oneOrMore().optional().greedy();

//期望出现两个或多个事件
start.timesOrMore(2);

//期望出现 2 次或 2 次以上,并尽可能多地重复
start.timesOrMore(2).greedy();

//期望出现 2 次或更多的事件,并尽可能多地重复或者不出现
start.timesOrMore(2).optional().greedy();
条件

可以通过 pattern.where()pattern.or()pattern.until() 方法指定事件属性的条件。条件可以是 IterativeConditionsSimpleConditions。比如 SimpleCondition 可以像下面这样使用:

1
2
3
4
5
6
start.where(new SimpleCondition<Event>() {
@Override
public boolean filter(Event value) {
return "zhisheng".equals(value.getName());
}
});

组合 Pattern

前面已经对单个 Pattern 做了详细对讲解,接下来讲解如何将多个 Pattern 进行组合来完成一些需求。在完成组合 Pattern 之前需要定义第一个 Pattern,然后在第一个的基础上继续添加新的 Pattern。比如定义了第一个 Pattern 如下:

1
Pattern<Event, ?> start = Pattern.<Event>begin("start");

接下来,可以为此指定更多的 Pattern,通过指定的不同的连接条件。比如:

  • next():要求比较严格,该事件一定要紧跟着前一个事件。
  • followedBy():该事件在前一个事件后面就行,两个事件之间可能会有其他的事件。
  • followedByAny():该事件在前一个事件后面的就满足条件,两个事件之间可能会有其他的事件,返回值比上一个多。
  • notNext():不希望前一个事件后面紧跟着该事件出现。
  • notFollowedBy():不希望后面出现该事件。

具体怎么写呢,可以看下样例:

1
2
3
4
5
6
7
8
9
Pattern<Event, ?> strict = start.next("middle").where(...);

Pattern<Event, ?> relaxed = start.followedBy("middle").where(...);

Pattern<Event, ?> nonDetermin = start.followedByAny("middle").where(...);

Pattern<Event, ?> strictNot = start.notNext("not").where(...);

Pattern<Event, ?> relaxedNot = start.notFollowedBy("not").where(...);

可能概念讲了很多,但是还是不太清楚,这里举个例子说明一下,假设有个 Pattern 是 a b,给定的数据输入顺序是 a c b b,对于上面那种不同的连接条件可能最后返回的值不一样。

  1. a 和 b 之间使用 next() 连接,那么则返回 {},即没有匹配到数据
  2. a 和 b 之间使用 followedBy() 连接,那么则返回 {a, b}
  3. a 和 b 之间使用 followedByAny() 连接,那么则返回 {a, b}, {a, b}

相信通过上面的这个例子讲解你就知道了它们的区别,尤其是 followedBy() 和 followedByAny(),笔者一开始也是毕竟懵,后面也是通过代码测试才搞明白它们之间的区别的。除此之外,还可以为 Pattern 定义时间约束。例如,可以通过 pattern.within(Time.seconds(10)) 方法定义此 Pattern 应该 10 秒内完成匹配。 该时间不仅支持处理时间还支持事件时间。另外还可以与 consecutive()、allowCombinations() 等组合,更多的请看下图中 Pattern 类的方法。

undefined

Group Pattern

业务需求比较复杂的场景,如果要使用 Pattern 来定义的话,可能这个 Pattern 会很长并且还会嵌套,比如由 begin、followedBy、followedByAny、next 组成和嵌套,另外还可以再和 oneOrMore()、times(#ofTimes)、times(#fromTimes, #toTimes)、optional()、consecutive()、allowCombinations() 等结合使用。效果如下面这种:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Pattern<Event, ?> start = Pattern.begin(
Pattern.<Event>begin("start").where(...).followedBy("start_middle").where(...)
);

//next 表示连续
Pattern<Event, ?> strict = start.next(
Pattern.<Event>begin("next_start").where(...).followedBy("next_middle").where(...)
).times(3);

//followedBy 代表在后面就行
Pattern<Event, ?> relaxed = start.followedBy(
Pattern.<Event>begin("followedby_start").where(...).followedBy("followedby_middle").where(...)
).oneOrMore();

//followedByAny
Pattern<Event, ?> nonDetermin = start.followedByAny(
Pattern.<Event>begin("followedbyany_start").where(...).followedBy("followedbyany_middle").where(...)
).optional();

关于上面这些 Pattern 操作的更详细的解释可以查看官网

事件匹配跳过策略

对于给定组合的复杂 Pattern,有的事件可能会匹配到多个 Pattern,如果要控制将事件的匹配数,需要指定跳过策略。在 Flink CEP 中跳过策略有四种类型,如下所示:

  • NO_SKIP:不跳过,将发出所有可能的匹配事件。
  • SKIP_TO_FIRST:丢弃包含 PatternName 第一个之前匹配事件的每个部分匹配。
  • SKIP_TO_LAST:丢弃包含 PatternName 最后一个匹配事件之前的每个部分匹配。
  • SKIP_PAST_LAST_EVENT:丢弃包含匹配事件的每个部分匹配。
  • SKIP_TO_NEXT:丢弃以同一事件开始的所有部分匹配。

这几种策略都是根据 AfterMatchSkipStrategy 来实现的,可以看下它们的类结构图,如下所示:

undefined

关于这几种跳过策略的具体区别可以查看官网,至于如何使用跳过策略,其实 AfterMatchSkipStrategy 抽象类中已经提供了 5 种静态方法可以直接使用,方法如下:

undefined

使用方法如下:

1
2
AfterMatchSkipStrategy skipStrategy = ...; // 使用 AfterMatchSkipStrategy 调用不同的静态方法
Pattern.begin("start", skipStrategy);

检测 Pattern

编写好了 Pattern 之后,你需要的是将其应用在流数据中去做匹配。这时要做的就是构造一个 PatternStream,它可以通过 CEP.pattern(eventDataStream, pattern) 来获取一个 PatternStream 对象,在 CEP.pattern() 方法中,你可以选择传入两个参数(DataStream 和 Pattern),也可以选择传入三个参数 (DataStream、Pattern 和 EventComparator),因为 CEP 类中它有两个不同参数数量的 pattern 方法。

1
2
3
4
5
6
7
8
9
10
11
public class CEP {

public static <T> PatternStream<T> pattern(DataStream<T> input, Pattern<T, ?> pattern) {
return new PatternStream(input, pattern);
}

public static <T> PatternStream<T> pattern(DataStream<T> input, Pattern<T, ?> pattern, EventComparator<T> comparator) {
PatternStream<T> stream = new PatternStream(input, pattern);
return stream.withComparator(comparator);
}
}

选择 Pattern

在获取到 PatternStream 后,你可以通过 select 或 flatSelect 方法从匹配到的事件流中查询。如果使用的是 select 方法,则需要实现传入一个 PatternSelectFunction 的实现作为参数,PatternSelectFunction 具有为每个匹配事件调用的 select 方法,该方法的参数是 Map>,这个 Map 的 key 是 Pattern 的名字,在前面入门案例中设置的 startmiddle 在这时就起作用了,你可以通过类似 get("start") 方法的形式来获取匹配到 start 的所有事件。如果使用的是 flatSelect 方法,则需要实现传入一个 PatternFlatSelectFunction 的实现作为参数,这个和 PatternSelectFunction 不一致地方在于它可以返回多个结果,因为这个接口中的 flatSelect 方法含有一个 Collector,它可以返回多个数据到下游去。两者的样例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
CEP.pattern(eventDataStream, pattern).select(new PatternSelectFunction<Event, String>() {
@Override
public String select(Map<String, List<Event>> p) throws Exception {
StringBuilder builder = new StringBuilder();
builder.append(p.get("start").get(0).getId()).append(",").append(p.get("start").get(0).getName()).append("\n")
.append(p.get("middle").get(0).getId()).append(",").append(p.get("middle").get(0).getName());
return builder.toString();
}
}).print();

CEP.pattern(eventDataStream, pattern).flatSelect(new PatternFlatSelectFunction<Event, String>() {
@Override
public void flatSelect(Map<String, List<Event>> map, Collector<String> collector) throws Exception {
for (Map.Entry<String, List<Event>> entry : map.entrySet()) {
collector.collect(entry.getKey() + " " + entry.getValue().get(0).getId() + "," + entry.getValue().get(0).getName());
}
}
}).print();

关于 PatternStream 中的 select 或 flatSelect 方法其实可以传入不同的参数,比如传入 OutputTag 和 PatternTimeoutFunction 去处理延迟的数据,具体查看下图。

undefined

如果使用的 Flink CEP 版本是大于等于 1.8 的话,还可以使用 process 方法,在上图中也可以看到在 PatternStream 类中包含了该方法。要使用 process 的话,得传入一个 PatternProcessFunction 的实现作为参数,在该实现中需要重写 processMatch 方法。使用 PatternProcessFunction 比使用 PatternSelectFunction 和 PatternFlatSelectFunction 更好的是,它支持获取应用的的上下文,那么也就意味着它可以访问时间(因为 Context 接口继承自 TimeContext 接口)。另外如果要处理延迟的数据可以与 TimedOutPartialMatchHandler 接口的实现类一起使用。

CEP 时间属性

根据事件时间处理延迟数据

在 CEP 中,元素处理的顺序很重要,当时间策略设置为事件时间时,为了确保能够按照事件时间的顺序来处理元素,先来的事件会暂存在缓冲区域中,然后对缓冲区域中的这些事件按照事件时间进行排序,当水印到达时,比水印时间小的事件会按照顺序依次处理的。这意味着水印之间的元素是按照事件时间顺序处理的。

注意:当作业设置的时间属性是事件时间是,CEP 中会认为收到的水印时间是正确的,会严格按照水印的时间来处理元素,从而保证能顺序的处理元素。另外对于这种延迟的数据(和 3.5 节中的延迟数据类似),CEP 中也是支持通过 side output 设置 OutputTag 标签来将其收集。使用方式如下:

1
2
3
4
5
6
7
8
9
10
11
PatternStream<Event> patternStream = CEP.pattern(inputDataStream, pattern);

OutputTag<String> lateDataOutputTag = new OutputTag<String>("late-data"){};

SingleOutputStreamOperator<ComplexEvent> result = patternStream
.sideOutputLateData(lateDataOutputTag)
.select(
new PatternSelectFunction<Event, ComplexEvent>() {...}
);

DataStream<String> lateData = result.getSideOutput(lateDataOutputTag);

时间上下文

在 PatternProcessFunction 和 IterativeCondition 中可以通过 TimeContext 访问当前正在处理的事件的时间(Event Time)和此时机器上的时间(Processing Time)。你可以查看到这两个类中都包含了 Context,而这个 Context 继承自 TimeContext,在 TimeContext 接口中定义了获取事件时间和处理时间的方法。

1
2
3
4
5
6
public interface TimeContext {

long timestamp();

long currentProcessingTime();
}

小结与反思

本节开始通过一个 Flink CEP 案例教大家上手,后面通过讲解 Flink CEP 的 Pattern API,更多详细的还是得去看官网文档,其实也建议大家好好的跟着官网的文档过一遍所有的 API,并跟着敲一些样例来实现,这样在开发需求的时候才能够及时的想到什么场景下该使用哪种 API,接着教了大家如何将 Pattern 与数据流结合起来匹配并获取匹配的数据,最后讲了下 CEP 中的时间概念。

你公司有使用 Flink CEP 吗?通常使用哪些 API 居多?

本节涉及代码地址:https://github.com/zhisheng17/flink-learning/tree/master/flink-learning-libraries/flink-learning-libraries-cep

State Processor API 介绍

能够从外部访问 Flink 作业的状态一直用户迫切需要的功能之一,在 Apache Flink 1.9.0 中新引入了 State Processor API,该 API 让用户可以通过 Flink DataSet 作业来灵活读取、写入和修改 Flink 的 Savepoint 和 Checkpoint。

一般来说,大多数的 Flink 作业都是有状态的,并且随着作业运行的时间越来越久,就会累积越多越多的状态,如果因为故障导致作业崩溃可能会导致作业的状态都丢失,那么对于比较重要的状态来说,损失就会很大。为了保证作业状态的一致性和持久性,Flink 从一开始使用的就是 Checkpoint 和 Savepoint 来保存状态,并且可以从 Savepoint 中恢复状态。在 Flink 的每个新 Release 版本中,Flink 社区添加了越来越多与状态相关的功能以提高 Checkpoint 的速度和恢复速度。

有的时候,用户可能会有这些需求场景,比如从第三方外部系统访问作业的状态、将作业的状态信息迁移到另一个应用程序等,目前现有支持查询作业状态的功能 Queryable State,但是在 Flink 中目前该功能只支持根据 Key 查找,并且不能保证返回值的一致性。另外该功能不支持添加和修改作业的状态,所以适用的场景还是比较有限。

使用 State Processor API 读写作业状态

在 1.9 版本中的 State Processor API,它完全和之前不一致,该功能使用 InputFormat 和 OutputFormat 扩展了 DataSet API 以读取和写入 Checkpoint 和 Savepoint 数据。由于 DataSet 和 Table API 的互通性,所以也可以使用 Table 或者 SQL API 查询和分析状态的数据。例如,再获取到正在运行的流作业状态的 Checkpoint 后,可以使用 DataSet 批处理程序对其进行分析,以验证该流作业的运行是否正确。另外 State Processor API 还可以修复不一致的状态信息,它提供了很多方法来开发有状态的应用程序,这些方法在以前的版本中因为设计的问题导致作业在启动后不能再修改,否则状态可能会丢失。现在,你可以任意修改状态的数据类型、调整算子的最大并行度、拆分或合并算子的状态、重新分配算子的 uid 等。

使用 DataSet 读取作业状态

State Processor API 将作业的状态映射到一个或多个可以单独处理的数据集,为了能够使用该 API,需要先了解这个映射的工作方式,首先来看下有状态的 Flink 作业是什么样子的。Flink 作业是由很多算子组成,通常是一个或多个数据源(Source)、一些实际处理数据的算子(比如 Map/Filter/FlatMap 等)和一个或者多个 Sink。每个算子会在一个或者多个任务中并行运行(取决于并行度),并且可以使用不同类型的状态,算子可能会有零个、一个或多个 Operator State,这些状态会组成一个以算子任务为范围的列表。如果是算子应用在 KeyedStream,它还有零个、一个或者多个 Keyed State,它们的作用域范围是从每个已处理数据中提取 Key,可以将 Keyed State 看作是一个分布式的 Map。

State Processor API 现在提供了读取、新增和修改 Savepoint 数据的方法,比如从已加载的 Savepoint 中读取数据集,然后将数据集转换为状态并将其保存到 Savepoint。下面分别讲解下这三种方法该如何使用。

读取现有的 Savepoint

读取状态首先需要指定一个 Savepoint(或者 Checkpoint) 的路径和状态后端存储的类型。

1
2
ExecutionEnvironment bEnv   = ExecutionEnvironment.getExecutionEnvironment();
ExistingSavepoint savepoint = Savepoint.load(bEnv, "hdfs://path/", new RocksDBStateBackend());

读取 Operator State 时,只需指定算子的 uid、状态名称和类型信息。

1
2
3
4
5
DataSet<Integer> listState  = savepoint.readListState("zhisheng-uid", "list-state", Types.INT);

DataSet<Integer> unionState = savepoint.readUnionState("zhisheng-uid", "union-state", Types.INT);

DataSet<Tuple2<Integer, Integer>> broadcastState = savepoint.readBroadcastState("zhisheng-uid", "broadcast-state", Types.INT, Types.INT);

如果在状态描述符(StateDescriptor)中使用了自定义类型序列化器 TypeSerializer,也可以指定它:

1
2
3
DataSet<Integer> listState = savepoint.readListState(
"zhisheng-uid", "list-state",
Types.INT, new MyCustomIntSerializer());

当读取 Keyed State 时,用户可以指定 KeyedStateReaderFunction 来读取任意列和复杂的状态类型,例如 ListState,MapState 和 AggregatingState。这意味着如果算子包含了有状态的处理函数,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class StatefulFunctionWithTime extends KeyedProcessFunction<Integer, Integer, Void> {

ValueState<Integer> state;

@Override
public void open(Configuration parameters) {
ValueStateDescriptor<Integer> stateDescriptor = new ValueStateDescriptor<>("state", Types.INT);
state = getRuntimeContext().getState(stateDescriptor);
}

@Override
public void processElement(Integer value, Context ctx, Collector<Void> out) throws Exception {
state.update(value + 1);
}
}

然后可以通过定义输出类型和相应的 KeyedStateReaderFunction 进行读取上面的状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class KeyedState {
Integer key;
Integer value;
}

class ReaderFunction extends KeyedStateReaderFunction<Integer, KeyedState> {
ValueState<Integer> state;

@Override
public void open(Configuration parameters) {
ValueStateDescriptor<Integer> stateDescriptor = new ValueStateDescriptor<>("state", Types.INT);
state = getRuntimeContext().getState(stateDescriptor);
}

@Override
public void readKey(Integer key, Context ctx, Collector<KeyedState> out) throws Exception {
KeyedState data = new KeyedState();
data.key = key;
data.value = state.value();
out.collect(data);
}
}

DataSet<KeyedState> keyedState = savepoint.readKeyedState("zhisheng-uid", new ReaderFunction());

注意:使用 KeyedStateReaderFunction 时,状态描述器(StateDescriptor)必须在 open 方法中注册,否则 RuntimeContext#getState,RuntimeContext#getListState 或 RuntimeContext#getMapState 将导致 RuntimeException。

写入新的 Savepoint

写入新的 Savepoint 主要是基于下面三个接口:

  • StateBootstrapFunction:用于写入未分区的 Operator State
  • BroadcastStateBootstrapFunction:用于写入 Broadcast State
  • KeyedStateBootstrapFunction:用于写入 Keyed State
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public  class Account {
public int id;

public double amount;

public long timestamp;
}

public class AccountBootstrapper extends KeyedStateBootstrapFunction<Integer, Account> {
ValueState<Double> state;

@Override
public void open(Configuration parameters) {
ValueStateDescriptor<Double> descriptor = new ValueStateDescriptor<>("total",Types.DOUBLE);
state = getRuntimeContext().getState(descriptor);
}

@Override
public void processElement(Account value, Context ctx) throws Exception {
state.update(value.amount);
}
}

ExecutionEnvironment bEnv = ExecutionEnvironment.getExecutionEnvironment();

DataSet<Account> accountDataSet = bEnv.fromCollection(accounts);

BootstrapTransformation<Account> transformation = OperatorTransformation
.bootstrapWith(accountDataSet)
.keyBy(acc -> acc.id)
.transform(new AccountBootstrapper());

该 KeyedStateBootstrapFunction 函数支持设置事件时间和处理时间的定时器,定时器不会在该函数中触发,只有在 DataStream 作业中还原后才会激活,如果设置了处理时间的定时器,但是该处理时间已经过期了,那么在恢复作业的时候会立即触发。一旦创建了一个或者多个算子,可以将它们合并为一个 Savepoint。

1
2
3
4
5
Savepoint
.create(backend, 128)
.withOperator("uid1", transformation1)
.withOperator("uid2", transformation2)
.write(savepointPath);

修改现有的 Savepoint

除了可以从头开始创建 Savepoint 之外,还可以基于现有的 Savepoint,例如在为现有作业添加新的算子。

1
2
3
4
Savepoint
.load(backend, oldPath)
.withOperator("uid", transformation)
.write(newPath);

删除或者覆盖现有 Savepoint 中的算子状态,并将其写入。

1
2
3
4
Savepoint
.removeOperator(oldOperatorUid)
.withOperator(oldOperatorUid, transformation)
.write(path)

为什么要使用 DataSet API?

社区一直在想将批和流统一,所以在未来 DataSet API 可能会废弃,那么为啥 State Processor API 还要基于 DataSet API 开发呢?这是因为社区在设计这个功能的时候,对 DataStream API 和 Table API 做了评估对比,但没有一个能满足需求的,而又因为 State Processor API 功能对于 Flink API 的进一步发展有至关重要的作用,因此社区决定在 DataSet API 构建 State Processor API 功能,但是尽可能的降低了对 DataSet API 的依赖性,方便后续迁移到其他的 API 中。

小结与反思

本节讲了 Flink 1.9 中的 State Processor API 的概念和如何使用,以及该功能的设计背景及需求。有关更多详细信息,请参见 FLIP-43。对于使用 DataSet API 来完成该功能,你有什么更好的解决方案吗?

ML 是 Machine Learning 的简称,Flink-ML 是 Flink 的机器学习类库。在 Flink 1.9 之前该类库是存在 flink-libraries 模块下的,但是在 Flink 1.9 版本中,为了支持 FLIP-39 ,所以该类库被移除了。

建立 FLIP-39 的目的主要是增强 Flink-ML 的可伸缩性和易用性。通常使用机器学习的有两类人,一类是机器学习算法库的开发者,他们需要一套标准的 API 来实现算法,每个机器学习算法会在这些 API 的基础上实现;另一类用户是直接利用这些现有的机器学习算法库去训练数据模型,整个训练是要通过很多转换或者算法才能完成的,所以如果能够提供 ML Pipeline,那么对于后一类用户来说绝对是一种福音。虽然在 1.9 中移除了之前的 Flink-ML 模块,但是在 Flink 项目下出现了一个 flink-ml-parent 的模块,该模块有两个子模块 flink-ml-apiflink-ml-lib

flink-ml-api 模块增加了 ML Pipeline 和 MLLib 的接口,它的类结构图如下:

undefined

  • Transformer: Transformer 是一种可以将一个表转换成另一个表的算法
  • Model: Model 是一种特别的 Transformer,它继承自 Transformer。它通常是由 Estimator 生成,Model 用于推断,输入一个数据表会生成结果表。
  • Estimator: Estimator 是一个可以根据一个数据表生成一个模型的算法。
  • Pipeline: Pipeline 描述的是机器学习的工作流,它将很多 Transformer 和 Estimator 连接在一起成一个工作流。
  • PipelineStage: PipelineStage 是 Pipeline 的基础节点,Transformer 和 Estimator 两个都继承自 PipelineStage 接口。
  • Params: Params 是一个参数容器。
  • WithParams: WithParams 有一个保存参数的 Params 容器。通常会使用在 PipelineStage 里面,因为几乎所有的算法都需要参数。

Flink-ML 的 pipeline 流程如下:

undefined

flink-ml-lib 模块包括了 DenseMatrix、DenseVector、SparseVector 等类的基本操作。这两个模块是 Flink-ML 的基础模块,相信社区在后面的稳定版本一定会带来更加完善的 Flink-ML 库。

虽然在 Flink 1.9 中已经移除了 Flink-ML 模块,但是在之前的版本还是支持的,如果你们公司使用的是低于 1.9 的版本,那么还是可以使用的,在使用之前引入依赖(假设使用的是 Flink 1.8 版本):

1
2
3
4
5
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-ml_2.11</artifactId>
<version>1.8.0</version>
</dependency>

另外如果是要运行的话还是要将 opt 目录下的 flink-ml_2.11-1.8.0.jar 移到 lib 目录下。下面演示下如何训练多元线性回归模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//带标签的特征向量
val trainingData: DataSet[LabeledVector] = ...
val testingData: DataSet[Vector] = ...

val dataSet: DataSet[LabeledVector] = ...
//使用 Splitter 将数据集拆分成训练数据和测试数据
val trainTestData: DataSet[TrainTestDataSet] = Splitter.trainTestSplit(dataSet)
val trainingData: DataSet[LabeledVector] = trainTestData.training
val testingData: DataSet[Vector] = trainTestData.testing.map(lv => lv.vector)

val mlr = MultipleLinearRegression()
.setStepsize(1.0)
.setIterations(100)
.setConvergenceThreshold(0.001)

mlr.fit(trainingData)

//已经形成的模型可以用来预测数据了
val predictions: DataSet[LabeledVector] = mlr.predict(testingData)

之前前面也讲解了 Pipeline 在 Flink-ML 的含义,那么下面演示一下如何通过 Flink-ML 构建一个 Pipeline 作业:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
val trainingData: DataSet[LabeledVector] = ...
val testingData: DataSet[Vector] = ...

val scaler = StandardScaler()
val polyFeatures = PolynomialFeatures().setDegree(3)
val mlr = MultipleLinearRegression()

// Construct pipeline of standard scaler, polynomial features and multiple linear regression
//构建标准定标器、多项式特征和多元线性回归的流水线
val pipeline = scaler.chainTransformer(polyFeatures).chainPredictor(mlr)

// Train pipeline
pipeline.fit(trainingData)

// Calculate predictions
val predictions: DataSet[LabeledVector] = pipeline.predict(testingData)

小结与反思

本节主要讲了下 Flink-ML 的发展以及为啥在 Flink 1.9 移除该库,并且介绍了其内部的接口和库函数,另外通过两个简短的代码讲解了下如何使用 Flink-ML。如果想了解更多 Flink-ML 的知识可以查看官网。

FLIP-39

Flink-ML

Gelly 是什么?

Gelly 是 Flink 的图 API 库,它包含了一组旨在简化 Flink 中图形分析应用程序开发的方法和实用程序。在 Gelly 中,可以使用类似于批处理 API 提供的高级函数来转换和修改图。Gelly 提供了创建、转换和修改图的方法以及图算法库。

如何使用 Gelly?

因为 Gelly 是 Flink 项目中库的一部分,它本身不在 Flink 的二进制包中,所以运行 Gelly 项目(Java 应用程序)是需要将 opt/flink-gelly_2.11-1.9.0.jar 移动到 lib 目录中,如果是 Scala 应用程序则需要将 opt/flink-gelly-scala_2.11-1.9.0.jar 移动到 lib 中,接着运行下面的命令就可以运行一个 flink-gelly-examples 项目。

1
2
3
4
./bin/flink run examples/gelly/flink-gelly-examples_2.11-1.9.0.jar \
--algorithm GraphMetrics --order directed \
--input RMatGraph --type integer --scale 20 --simplify directed \
--output print

接下来可以在 UI 上看到运行的结果:

undefined

如果是自己创建的 Gelly Java 应用程序,则需要添加如下依赖:

1
2
3
4
5
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-gelly_${scala.binary.version}</artifactId>
<version>${flink.version}</version>
</dependency>

如果是 Gelly Scala 应用程序,添加下面的依赖:

1
2
3
4
5
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-gelly-scala_${scala.binary.version}</artifactId>
<version>${flink.version}</version>
</dependency>

Gelly API

Graph 介绍

在 Gelly 中,一个图(Graph)由顶点的数据集(DataSet)和边的数据集(DataSet)组成。图中的顶点由 Vertex 类型来表示,一个 Vertex 由唯一的 ID 和一个值来表示。其中 Vertex 的 ID 必须是全局唯一的值,且实现了 Comparable 接口。如果节点不需要由任何值,则该值类型可以声明成 NullValue 类型。

1
2
3
4
5
//创建一个 Vertex<Long,String>
Vertex<Long, String> v = new Vertex<Long, String>(1L, "foo");

//创建一个 Vertex<Long,NullValue>
Vertex<Long, NullValue> v = new Vertex<Long, NullValue>(1L, NullValue.getInstance());

Graph 中的边由 Edge 类型来表示,一个 Edge 通常由源顶点的 ID,目标顶点的 ID 以及一个可选的值来表示。其中源顶点和目标顶点的类型必须与 Vertex 的 ID 类型相同。同样的,如果边不需要由任何值,则该值类型可以声明成 NullValue 类型。

1
2
3
4
Edge<Long, Double> e = new Edge<Long, Double>(1L, 2L, 0.5);
//反转此 edge 的源和目标
Edge<Long, Double> reversed = e.reverse();
Double weight = e.getValue(); // weight = 0.5

在 Gelly 中,一个 Edge 总是从源顶点指向目标顶点。如果图中每条边都能匹配一个从目标顶点到源顶点的 Edge,那么这个图可能是个无向图。同样地,无向图可以用这个方式来表示。

创建 Graph

可以通过以下几种方式创建一个 Graph:

  • 从一个 Edge 数据集合和一个 Vertex 数据集合中创建图。
1
2
3
4
5
6
ExecutionEnvironment env = ExecutionEnvironment.getExecutionEnvironment();

DataSet<Vertex<String, Long>> vertices = ...
DataSet<Edge<String, Double>> edges = ...

Graph<String, Long, Double> graph = Graph.fromDataSet(vertices, edges, env);
  • 从一个表示边的 Tuple2 数据集合中创建图。Gelly 会将每个 Tuple2 转换成一个 Edge,其中第一个元素表示源顶点的 ID,第二个元素表示目标顶点的 ID,图中的顶点和边的 value 值均被设置为 NullValue。
1
2
3
4
5
ExecutionEnvironment env = ExecutionEnvironment.getExecutionEnvironment();

DataSet<Tuple2<String, String>> edges = ...

Graph<String, NullValue, NullValue> graph = Graph.fromTuple2DataSet(edges, env);
  • 从一个 Tuple3 数据集和一个可选的 Tuple2 数据集中生成图。在这种情况下,Gelly 会将每个 Tuple3 转换成 Edge,其中第一个元素域是源顶点 ID,第二个域是目标顶点 ID,第三个域是边的值。同样的,每个 Tuple2 会转换成一个顶点 Vertex,其中第一个域是顶点的 ID,第二个域是顶点的 value。
1
2
3
4
5
6
7
ExecutionEnvironment env = ExecutionEnvironment.getExecutionEnvironment();

DataSet<Tuple2<String, Long>> vertexTuples = env.readCsvFile("path/to/vertex/input").types(String.class, Long.class);

DataSet<Tuple3<String, String, Double>> edgeTuples = env.readCsvFile("path/to/edge/input").types(String.class, String.class, Double.class);

Graph<String, Long, Double> graph = Graph.fromTupleDataSet(vertexTuples, edgeTuples, env);
  • 从一个表示边数据的CSV文件和一个可选的表示节点的CSV文件中生成图。在这种情况下,Gelly会将表示边的CSV文件中的每一行转换成一个Edge,其中第一个域表示源顶点ID,第二个域表示目标顶点ID,第三个域表示边的值。同样的,表示节点的CSV中的每一行都被转换成一个Vertex,其中第一个域表示顶点的ID,第二个域表示顶点的值。为了通过GraphCsvReader生成图,需要指定每个域的类型,可以使用 types、edgeTypes、vertexTypes、keyType 中的方法。
1
2
3
4
5
6
//创建一个具有字符串 Vertex id、Long Vertex 和双边缘的图
Graph<String, Long, Double> graph = Graph.fromCsvReader("path/to/vertex/input", "path/to/edge/input", env)
.types(String.class, Long.class, Double.class);

//创建一个既没有顶点值也没有边值的图
Graph<Long, NullValue, NullValue> simpleGraph = Graph.fromCsvReader("path/to/edge/input", env).keyType(Long.class);
  • 从一个边的集合和一个可选的顶点的集合中生成图。如果在图创建的时候顶点的集合没有传入,Gelly 会依据数据的边数据集合自动地生成一个 Vertex 集合。这种情况下,创建的节点是没有值的。或者也可以像下面一样,在创建图的时候提供一个 MapFunction 方法来初始化节点的值。
1
2
3
4
5
6
7
8
9
10
11
12
13
List<Vertex<Long, Long>> vertexList = new ArrayList...

List<Edge<Long, String>> edgeList = new ArrayList...

Graph<Long, Long, String> graph = Graph.fromCollection(vertexList, edgeList, env);

//将顶点值初始化为顶点ID
Graph<Long, Long, String> graph = Graph.fromCollection(edgeList,
new MapFunction<Long, Long>() {
public Long map(Long value) {
return value;
}
}, env);

Graph 属性

Gelly 提供了下列方法来查询图的属性和指标:

1
2
3
4
5
6
7
8
9
10
11
12
DataSet<Vertex<K, VV>> getVertices()
//获取边缘数据集
DataSet<Edge<K, EV>> getEdges()
//获取顶点的 id 数据集
DataSet<K> getVertexIds()
DataSet<Tuple2<K, K>> getEdgeIds()
DataSet<Tuple2<K, LongValue>> inDegrees()
DataSet<Tuple2<K, LongValue>> outDegrees()
DataSet<Tuple2<K, LongValue>> getDegrees()
long numberOfVertices()
long numberOfEdges()
DataSet<Triplet<K, VV, EV>> getTriplets()

Graph 转换

  • Map:Gelly 提供了专门的用于转换顶点值和边值的方法。mapVertices 和 mapEdges 会返回一个新图,图中的每个顶点和边的 ID 不会改变,但是顶点和边的值会根据用户自定义的映射方法进行修改。这些映射方法同时也可以修改顶点和边的值的类型。
  • Translate:Gelly 还提供了专门用于根据用户定义的函数转换顶点和边的 ID 和值的值及类型的方法(translateGraphIDs/translateVertexValues/translateEdgeValues),是Map 功能的升级版,因为 Map 操作不支持修订顶点和边的 ID。
  • Filter:Gelly 支持在图中的顶点上或边上执行一个用户指定的 filter 转换。filterOnEdges 会根据提供的在边上的断言在原图的基础上生成一个新的子图,注意,顶点的数据不会被修改。同样的 filterOnVertices 在原图的顶点上进行 filter 转换,不满足断言条件的源节点或目标节点会在新的子图中移除。该子图方法支持同时对顶点和边应用 filter 函数。
  • undefined
  • Reverse:Gelly中得reverse()方法用于在原图的基础上,生成一个所有边方向与原图相反的新图。
  • Undirected:在前面的内容中,我们提到过,Gelly中的图通常都是有向的,而无向图可以通过对所有边添加反向的边来实现,出于这个目的,Gelly提供了getUndirected()方法,用于获取原图的无向图。
  • Union:Gelly的union()操作用于联合当前图和指定的输入图,并生成一个新图,在输出的新图中,相同的节点只保留一份,但是重复的边会保留。如下图所示:
  • undefined
  • Difference:Gelly提供了difference()方法用于发现当前图与指定的输入图之间的差异。
  • Intersect:Gelly提供了intersect()方法用于发现两个图中共同存在的边,并将相同的边以新图的方式返回。相同的边指的是具有相同的源顶点,相同的目标顶点和相同的边值。返回的新图中,所有的节点没有任何值,如果需要节点值,可以使用joinWithVertices()方法去任何一个输入图中检索。

Graph 变化

Gelly 内置下列方法以支持对一个图进行节点和边的增加/移除操作:

1
2
3
4
5
6
7
8
Graph<K, VV, EV> addVertex(final Vertex<K, VV> vertex)
Graph<K, VV, EV> addVertices(List<Vertex<K, VV>> verticesToAdd)
Graph<K, VV, EV> addEdge(Vertex<K, VV> source, Vertex<K, VV> target, EV edgeValue)
Graph<K, VV, EV> addEdges(List<Edge<K, EV>> newEdges)
Graph<K, VV, EV> removeVertex(Vertex<K, VV> vertex)
Graph<K, VV, EV> removeVertices(List<Vertex<K, VV>> verticesToBeRemoved)
Graph<K, VV, EV> removeEdge(Edge<K, EV> edge)
Graph<K, VV, EV> removeEdges(List<Edge<K, EV>> edgesToBeRemoved)

Neighborhood Methods

邻接方法允许每个顶点针对其所有的邻接顶点或边执行某个集合操作。reduceOnEdges() 可以用于计算顶点所有邻接边的值的集合。reduceOnNeighbors() 可以用于计算邻接顶点的值的集合。这些方法采用联合和交换集合,并在内部利用组合器,显著提高了性能。邻接的范围由 EdgeDirection 来确定,它有三个枚举值,分别是:IN/OUT/ALL,其中 IN 只考虑所有入的邻接边和顶点,OUT 只考虑所有出的邻接边和顶点,而 ALL 考虑所有的邻接边和顶点。举个例子,如下图所示,假设我们想要知道图中出度最小的边权重。

undefined

下列代码会为每个节点找到出的边集合,然后在集合的基础上执行一个用户定义的方法 SelectMinWeight()。

1
2
3
4
5
6
7
8
9
10
11
12
Graph<Long, Long, Double> graph = ...

DataSet<Tuple2<Long, Double>> minWeights = graph.reduceOnEdges(new SelectMinWeight(),
EdgeDirection.OUT);

static final class SelectMinWeight implements ReduceEdgesFunction<Double> {

@Override
public Double reduceEdges(Double firstEdgeValue, Double secondEdgeValue) {
return Math.min(firstEdgeValue, secondEdgeValue);
}
}

undefined

同样的,假设我们需要知道每个顶点的所有邻接边上的权重的值之和,不考虑方向。可以用下面的代码来实现:

1
2
3
4
5
6
7
8
9
10
11
12
Graph<Long, Long, Double> graph = ...

DataSet<Tuple2<Long, Long>> verticesWithSum = graph.reduceOnNeighbors(new SumValues(),
EdgeDirection.IN);

static final class SumValues implements ReduceNeighborsFunction<Long> {

@Override
public Long reduceNeighbors(Long firstNeighbor, Long secondNeighbor) {
return firstNeighbor + secondNeighbor;
}
}

结果如下图所示:

undefined

Graph 验证

Gelly 提供了一个简单的工具用于对输入的图进行校验操作。由于应用程序上下文的不同,根据某些标准,有些图可能有效,也可能无效。例如用户需要校验图中是否包含重复的边。为了校验一个图,可以定义一个定制的 GraphValidator 并实现它的 validate() 方法。InvalidVertexIdsValidator 是 Gelly 预定义的一个校验器,用来校验边上所有的顶点 ID 是否有效,即边上的顶点 ID 在顶点集合中存在。

小结与反思

本节开始对 Gelly 做了个简单的介绍,然后教大家如何使用 Gelly,接着介绍了 Gelly API,更多关于 Gelly 可以查询官网。 你们公司有什么场景在用该库开发吗?

在讲解 7.2 节中如何部署 Flink 作业之前,希望能够再细讲下 Flink 中的配置,虽然在 2.2 节中简单讲解过。

基础配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# jobManager 的IP地址
jobmanager.rpc.address: localhost

# JobManager 的端口号
jobmanager.rpc.port: 6123

# JobManager JVM heap 内存大小
jobmanager.heap.size: 1024m

# TaskManager JVM heap 内存大小
taskmanager.heap.size: 1024m

# 每个 TaskManager 提供的任务 slots 数量大小

taskmanager.numberOfTaskSlots: 1

# 程序默认并行计算的个数
parallelism.default: 1

# 文件系统来源
# fs.default-scheme

高可用性配置

1
2
3
4
5
6
7
8
9
10
11
# 可以选择 'NONE' 或者 'zookeeper'.
# high-availability: zookeeper

# 文件系统路径,让 Flink 在高可用性设置中持久保存元数据
# high-availability.storageDir: hdfs:///flink/ha/

# zookeeper 集群中仲裁者的机器 ip 和 port 端口号
# high-availability.zookeeper.quorum: localhost:2181

# 默认是 open,如果 zookeeper security 启用了该值会更改成 creator
# high-availability.zookeeper.client.acl: open

容错和检查点配置

1
2
3
4
5
6
7
8
9
10
11
# 用于存储和检查点状态
# state.backend: filesystem

# 存储检查点的数据文件和元数据的默认目录
# state.checkpoints.dir: hdfs://namenode-host:port/flink-checkpoints

# savepoints 的默认目标目录(可选)
# state.savepoints.dir: hdfs://namenode-host:port/flink-checkpoints

# 用于启用/禁用增量 checkpoints 的标志
# state.backend.incremental: false

Web 前端配置

1
2
3
4
5
6
7
8
# 基于 Web 的运行时监视器侦听的地址.
#jobmanager.web.address: 0.0.0.0

# Web 的运行时监视器端口
rest.port: 8081

# 是否从基于 Web 的 jobmanager 启用作业提交
# jobmanager.web.submit.enable: false

高级配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# io.tmp.dirs: /tmp

# 是否应在 TaskManager 启动时预先分配 TaskManager 管理的内存
# taskmanager.memory.preallocate: false

# 类加载解析顺序,是先检查用户代码 jar(“child-first”)还是应用程序类路径(“parent-first”)。 默认设置指示首先从用户代码 jar 加载类
# classloader.resolve-order: child-first


# 用于网络缓冲区的 JVM 内存的分数。 这决定了 TaskManager 可以同时拥有多少流数据交换通道以及通道缓冲的程度。 如果作业被拒绝或者您收到系统没有足够缓冲区的警告,请增加此值或下面的最小/最大值。 另请注意,“taskmanager.network.memory.min”和“taskmanager.network.memory.max”可能会覆盖此分数

# taskmanager.network.memory.fraction: 0.1
# taskmanager.network.memory.min: 67108864
# taskmanager.network.memory.max: 1073741824

Flink 集群安全配置

1
2
3
4
5
6
7
8
9
10
11
# 指示是否从 Kerberos ticket 缓存中读取
# security.kerberos.login.use-ticket-cache: true

# 包含用户凭据的 Kerberos 密钥表文件的绝对路径
# security.kerberos.login.keytab: /path/to/kerberos/keytab

# 与 keytab 关联的 Kerberos 主体名称
# security.kerberos.login.principal: flink-user

# 以逗号分隔的登录上下文列表,用于提供 Kerberos 凭据(例如,`Client,KafkaClient`使用凭证进行 ZooKeeper 身份验证和 Kafka 身份验证)
# security.kerberos.login.contexts: Client,KafkaClient

ZooKeeper 安全配置

1
2
3
4
5
# 覆盖以下配置以提供自定义 ZK 服务名称
# zookeeper.sasl.service-name: zookeeper

# 该配置必须匹配 "security.kerberos.login.contexts" 中的列表(含有一个)
# zookeeper.sasl.login-context-name: Client

HistoryServer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 你可以通过 bin/historyserver.sh (start|stop) 命令启动和关闭 HistoryServer

# 将已完成的作业上传到的目录
# jobmanager.archive.fs.dir: hdfs:///completed-jobs/

# 基于 Web 的 HistoryServer 的地址
# historyserver.web.address: 0.0.0.0

# 基于 Web 的 HistoryServer 的端口号
# historyserver.web.port: 8082

# 以逗号分隔的目录列表,用于监视已完成的作业
# historyserver.archive.fs.dir: hdfs:///completed-jobs/

# 刷新受监控目录的时间间隔(以毫秒为单位)
# historyserver.archive.fs.refresh-interval: 10000

masters

以 host:port 构成

1
localhost:8081

slaves

里面是每个 worker 节点的 IP/Hostname,每一个 worker 结点之后都会运行一个 TaskManager,一个一行。

1
localhost

Log 配置

在 Flink 的日志配置文件(logback.xmllog4j.properties)中有配置日志存储的地方,logback.xml 配置日志存储的路径是:

1
2
3
4
5
6
7
<appender name="file" class="ch.qos.logback.core.FileAppender">
<file>${log.file}</file>
<append>false</append>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{60} %X{sourceThread} - %msg%n</pattern>
</encoder>
</appender>

log4j.propertieslog4j-cli.properties 的配置日志存储的路径是:

1
log4j.appender.file.file=${log.file}

从上面两个配置可以看到日志的路径都是由 log.file 变量控制的,如果系统变量没有配置的话,则会使用 bin/flink 脚本里配置的值。

1
2
log=$FLINK_LOG_DIR/flink-$FLINK_IDENT_STRING-client-$HOSTNAME.log
log_setting=(-Dlog.file="$log" -Dlog4j.configuration=file:"$FLINK_CONF_DIR"/log4j-cli.properties -Dlogback.configurationFile=file:"$FLINK_CONF_DIR"/logback.xml)

从上面可以看到 log 里配置的 FLINKLOGDIR 变量是在 bin 目录下的 config.sh 里初始化的。

1
2
3
4
5
6
DEFAULT_FLINK_LOG_DIR=$FLINK_HOME_DIR_MANGLED/log
KEY_ENV_LOG_DIR="env.log.dir"

if [ -z "${FLINK_LOG_DIR}" ]; then
FLINK_LOG_DIR=$(readFromConfig ${KEY_ENV_LOG_DIR} "${DEFAULT_FLINK_LOG_DIR}" "${YAML_CONF}")
fi

从上面可以知道日志默认就是在 Flink 的 log 目录下,你可以通过在 flink-conf.yaml 配置文件中配置 env.log.dir 参数来更改保存日志的目录。另外通过源码可以发现,如果找不到 log.file 环境变量,则会去找 web.log.path 的配置,但是该配置在 Standalone 下是不起作用的,日志依旧是会在 log 目录,在 YARN 下是会起作用的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public static LogFileLocation find(Configuration config) {
final String logEnv = "log.file";
String logFilePath = System.getProperty(logEnv);

if (logFilePath == null) {
LOG.warn("Log file environment variable '{}' is not set.", logEnv);
logFilePath = config.getString(WebOptions.LOG_PATH); //该值为 web.log.path
}

// not configured, cannot serve log files
if (logFilePath == null || logFilePath.length() < 4) {
LOG.warn("JobManager log files are unavailable in the web dashboard. " +
"Log file location not found in environment variable '{}' or configuration key '{}'.",
logEnv, WebOptions.LOG_PATH);
return new LogFileLocation(null, null);
}

String outFilePath = logFilePath.substring(0, logFilePath.length() - 3).concat("out");

LOG.info("Determined location of main cluster component log file: {}", logFilePath);
LOG.info("Determined location of main cluster component stdout file: {}", outFilePath);

return new LogFileLocation(resolveFileLocation(logFilePath), resolveFileLocation(outFilePath));
}

/**
* The log file location (may be in /log for standalone but under log directory when using YARN).
*/
public static final ConfigOption<String> LOG_PATH =
key("web.log.path")
.noDefaultValue()
.withDeprecatedKeys("jobmanager.web.log.path")
.withDescription("Path to the log file (may be in /log for standalone but under log directory when using YARN).");

另外可能会在本地 IDE 中运行作业出不来日志的情况,这时请检查是否有添加日志的依赖。

1
2
3
4
5
6
7
8
9
10
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>1.7.25</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>1.7.25</version>
</dependency>

如何配置 JobManager 高可用?

JobManager 协调每个 Flink 作业的部署,它负责调度和资源管理。默认情况下,每个 Flink 集群只有一个 JobManager 实例,这样就可能会产生单点故障,如果 JobManager 崩溃,则无法提交新作业且运行中的作业也会失败。如果保证 JobManager 的高可用,则可以避免这个问题。下面分别下如何搭建 Standalone 集群和 YARN 集群高可用的 JobManager。

搭建 Standalone 集群高可用 JobManager

Standalone 集群的 JobManager 高可用性的概念是:任何时候只有一个主 JobManager 和多个备 JobManager,以便在主节点失败时有新的 JobManager 接管集群。这样就保证了没有单点故障,一旦备 JobManager 接管集群,作业就可以依旧正常运行。主备 JobManager 实例之间没有明确的区别,每个 JobManager 都可以充当主备节点。例如,请考虑以下三个 JobManager 实例的设置。

undefined

如何配置

要启用 JobManager 高可用性功能,首先必须在配置文件 flink-conf.yaml 中将高可用性模式设置为 ZooKeeper,配置 ZooKeeper quorum,将所有 JobManager 主机及其 Web UI 端口写入配置文件。每个 ip:port 都是一个 ZooKeeper 服务器的 ip 及其端口,Flink 可以通过指定的地址和端口访问 ZooKeeper。另外就是高可用存储目录,JobManager 元数据保存在 high-availability.storageDir 指定的文件系统中,在 ZooKeeper 中仅保存了指向此状态的指针, 推荐这个目录是 HDFS、S3、Ceph、NFS 等,该文件系统中保存了 JobManager 恢复状态需要的所有元数据。

1
2
3
high-availability: zookeeper
high-availability.zookeeper.quorum: ip1:2181 [,...],ip2:2181
high-availability.storageDir: hdfs:///flink/ha/

Flink 利用 ZooKeeper 在所有正在运行的 JobManager 实例之间进行分布式协调。ZooKeeper 是独立于 Flink 的服务,通过 leader 选举和轻量级一致性状态存储提供高可靠的分布式协调服务。Flink 包含用于 Bootstrap ZooKeeper 安装的脚本。 它在我们的 Flink 安装路径下面 /conf/zoo.cfg 。

1
2
3
4
5
6
7
8
9
tickTime=2000
initLimit=10
syncLimit=5
# dataDir=/tmp/zookeeper
clientPort=2181
# ZooKeeper quorum peers
# 下面这个配置 ZK 地址
server.1=localhost:2888:3888
# server.2=host:peer-port:leader-port

要启动 HA 集群,请配置 masters 文件,该文件包含启动 JobManager 的所有主机以及 Web 用户界面绑定的端口,一行写一个。

1
2
localhost:8081
xxx.xxx.xxx.xxx:8081

默认情况下,JobManager 选一个随机端口作为进程通信端口,可以通过 high-availability.jobmanager.port 更改此设置。此配置接受单个端口(例如 50010),范围(50000-50025)或两者的组合(50010,50011,50020-50025,50050-50075)。

启动

配置好了之后的示例如下,假设是配置两个 JobManager 的 Standalone 的集群,在 flink-conf.yaml 中配置高可用模式和 Zookeeper 如下:

1
2
3
high-availability: zookeeper
high-availability.zookeeper.quorum: localhost:2181
high-availability.storageDir: hdfs:///flink/recovery

masters 中配置如下:

1
2
localhost:8081
localhost:8082

在 zoo.cfg 中配置 Zookeeper 服务如下:

1
server.0=localhost:2888:3888

启动 ZooKeeper 集群:

1
2
$ bin/start-zookeeper-quorum.sh
Starting zookeeper daemon on host localhost.

启动一个 Flink HA 集群:

1
2
3
4
5
$ bin/start-cluster.sh
Starting HA cluster with 2 masters and 1 peers in ZooKeeper quorum.
Starting jobmanager daemon on host localhost.
Starting jobmanager daemon on host localhost.
Starting taskmanager daemon on host localhost.

停止 ZooKeeper 和集群:

1
2
3
4
5
6
7
$ bin/stop-cluster.sh
Stopping taskmanager daemon (pid: 7647) on localhost.
Stopping jobmanager daemon (pid: 7495) on host localhost.
Stopping jobmanager daemon (pid: 7349) on host localhost.

$ bin/stop-zookeeper-quorum.sh
Stopping zookeeper daemon (pid: 7101) on host localhost.

搭建 YARN 集群高可用 JobManager

当在 YARN 上配置高可用的 JobManager 时,它只会运行一个 JobManager 实例,不会运行多个,该 JobManager 实例失败时,YARN 会将其重新启动。Yarn 的具体行为取决于使用的 YARN 版本。

如何配置

在 YARN 配置文件 yarn-site.xml 中,需要配置 application master 的最大重试次数:

1
2
3
4
5
6
7
<property>
<name>yarn.resourcemanager.am.max-attempts</name>
<value>4</value>
<description>
The maximum number of application master execution attempts.
</description>
</property>

当前 YARN 版本的默认值为 2(表示允许单个 JobManager 失败两次)。除了上面可以配置最大重试次数外,还可以在 flink-conf.yaml 配置如下:

1
yarn.application-attempts: 10

这意味着在如果程序启动失败,YARN 会再重试 9 次(9 次重试 + 1 次启动),如果启动 10 次作业还失败,YARN 才会将该任务的状态置为失败。如果因为节点硬件故障或重启,NodeManager 重新同步等操作,需要 YARN 继续尝试启动应用。这些重启尝试不计入 yarn.application-attempts 个数中。

容器关闭行为

  • YARN 2.3.0 < 版本 < 2.4.0. 如果 application master 进程失败,则所有的 container 都会重启。
  • YARN 2.4.0 < 版本 < 2.6.0. TaskManager container 在 application master 故障期间,会继续工作。这具有以下优点:作业恢复时间更快,且缩短所有 TaskManager 启动时申请资源的时间。
  • YARN 2.6.0 <= version: 将尝试失败有效性间隔设置为 Flink 的 Akka 超时值。尝试失败有效性间隔表示只有在系统在一个间隔期间看到最大应用程序尝试次数后才会终止应用程序,这避免了持久的工作会耗尽它的应用程序尝试。

启动

配置好了的示例如下,首先在 flink-conf.yaml 配置 HA 模式和 Zookeeper 集群:

1
2
3
high-availability: zookeeper
high-availability.zookeeper.quorum: localhost:2181
yarn.application-attempts: 10

在 zoo.cfg 配置 ZooKeeper 服务:

1
server.0=localhost:2888:3888

启动 Zookeeper 集群:

1
2
$ bin/start-zookeeper-quorum.sh
Starting zookeeper daemon on host localhost.

启动 HA 集群:

1
$ bin/yarn-session.sh -n 2

小结与反思

本节一开始对 Flink 的所有配置文件做了一个详细的介绍,分析了每种配置的作用和使用场景,然后介绍了 Flink 中的日志配置,最后讲解了下 JobManager 的高可用配置。

前面内容已经有很多学习案列带大家正式使用了 Flink,其中不仅有讲将 Flink 应用程序在 IDEA 中运行,也有讲将 Flink Job 编译打包上传到 Flink UI 上运行,在这 UI 背后可能是 YARN、Mesos、Kubernetes。那么这节就系统讲下如何部署和运行我们的 Flink Job,大家可以根据自己公司的场景进行选择!

在讲解完 Flink 中的配置后,接下来接着讲解 Flink 的部署情况。

Standalone

第一种方式就是 Standalone 模式,这种模式笔者在前面 2.2 节里面演示的就是这种,我们通过执行命令:./bin/start-cluster.sh 启动一个 Flink Standalone 集群。

1
2
3
4
zhisheng@zhisheng  /usr/local/flink-1.9.0  ./bin/start-cluster.sh
Starting cluster.
Starting standalonesession daemon on host zhisheng.
Starting taskexecutor daemon on host zhisheng.

默认的话是启动一个 Job Manager 和一个 Task Manager,我们可以通过 jps 查看进程有:

1
2
3
65425 Jps
51572 TaskManagerRunner
51142 StandaloneSessionClusterEntrypoint

其中上面的 TaskManagerRunner 代表的是 Task Manager 进程,StandaloneSessionClusterEntrypoint 代表的是 Job Manager 进程。上面运行产生的只有一个 Job Manager 和一个 Task Manager,如果是生产环境的话,这样的配置肯定是不够运行多个 Job 的,那么我们该如何在生产环境中配置 standalone 模式的集群呢?我们就需要修改 Flink 安装目录下面的 conf 文件夹里面配置:

1
2
3
flink-conf.yaml
masters
slaves

将 slaves 中再添加一个 localhost,这样就可以启动两个 Task Manager 了。接着启动脚本 start-cluster.sh,启动日志显示如下:

undefined

可以看见有两个 Task Manager 启动了,再看下 UI 显示的:

undefined

那么如果还想要添加一个 Job Manager 或者 Task Manager 怎么办?总不能再次重启修改配置文件后然后再重启吧!这里你可以这样操作。

增加一个 Job Manager

1
bin/jobmanager.sh ((start|start-foreground) [host] [webui-port])|stop|stop-all

但是注意 Standalone 下最多只能运行一个 Job Manager。

增加一个 Task Manager

1
bin/taskmanager.sh start|start-foreground|stop|stop-all

比如我执行了 ./bin/taskmanager.sh start 命令后:

undefined

Standalone 模式下可以先对 Flink Job 通过 mvn clean package 编译打包,得到 Jar 包后,可以在 UI 上直接上传 Jar 包,然后点击 Submit 就可以运行了。

YARN

Flink 不仅仅支持以 standalone 模式运行,还支持在 YARN 上运行,YARN 是一种新的 Hadoop 资源管理器,它是一个通用资源管理系统,可为上层应用提供统一的资源管理和调度,它的引入为集群在利用率、资源统一管理和数据共享等方面带来了巨大好处。

相当于 standalone 模式,Flink on YARN 有如下好处:

  • 资源按需使用,提高集群的资源利用率
  • 基于 YARN 调度系统,能够自动处理各个角色的 failover(Job Manager 进程异常退出,Yarn ResourceManager 会重新调度 Job Manager 到其他机器;如果 Task Manager 进程异常退出,Job Manager 收到消息后并重新向 Yarn ResourceManager 申请资源,重新启动 Task Manager)

下图是 Flink on YARN 的架构图:

undefined

官网 对 Flink On YARN 讲解很多,包含 Flink 在 YARN 上的安装方式、 Flink YARN Session 和怎么允许单一的 Flink Job、怎么查看在 YARN 上查看日志,已经非常全了,大家可以多多参考官网。

Mesos

Apache Mesos 诞生于 UC Berkeley 的一个研究项目,现已成为 Apache Incubator 中的项目。Apache Mesos 把自己定位成一个数据中心操作系统,它能管理上万台的从机。Framework 相当于这个操作系统的应用程序,每当应用程序需要执行,Framework 就会在 Mesos 中选择一台有合适资源(CPU、内存等)的从机来运行。

Flink 也是支持在 Mesos 上部署运行的,在官网也有介绍,主要是讲 Flink 运行在 DC/OS(它是具有复杂应用程序管理层的 Mesos 分支,预装了Marathon),如果安装好了 DC/OS 的话,那么你可以使用它的 CLI 工具来安装 Flink,比如:

1
dcos package install flink

在 Mesos 上运行 Flink 有两种方式:Flink 会话集群(session cluster)和作业集群(job cluster)。

会话集群

Flink 会话集群需要在运行的 Mesos 上部署执行,然后你可以在一个会话集群上运行多个 Flink 作业,在部署会话集群之后,需要将每个作业提交给集群。在 Flink 的安装目录 bin 下,你可以找到 2 个在 Mesos 上启动 Flink 的脚本。

undefined

  • mesos-appmaster.sh:它将启动 Mesos 应用程序主程序,会注册 Mesos 调度程序,负责启动工作节点
  • mesos-taskmanager.sh:Mesos 进程的入口点,你不需要手动执行该脚本,它由 Mesos 工作节点自动启动来启动新的 Task Manager。

在运行 mesos-appmaster.sh 脚本之前,你需要在 flink-conf.yaml 中定义 mesos.master 配置或者通过启动参数 -Dmesos.master=... 传给进程。当执行 mesos-appmaster.sh 后,它会在执行该脚本的机器上创建一个 Job Manager,另外就是 Task Manager 将作为 Mesos 集群中的任务来运行。

作业集群

Flink 作业集群是一个运行单个作业的专用集群,不需要额外的作业提交。在 Flink 的安装目录 bin 下面有 mesos-appmaster-job.sh 脚本,该脚本会启动 Mesos 应用程序主程序,会注册 Mesos 调度程序,检索到 JobGraph 后相应的启动 Task Manager。

在执行 mesos-appmaster-job.sh 脚本之前,你需要在 flink-conf.yaml 中定义 mesos.master 和 internal.jobgraph-path 或者你可以通过启动参数 -Dmesos.master=... -Dinterval.jobgraph-path=... 传入进程。

你可以这样获取到 JobGraph:

1
2
3
4
5
6
7
8
final JobGraph jobGraph = env.getStreamGraph().getJobGraph();
jobGraph.setAllowQueuedScheduling(true);
final String jobGraphFilename = "job.graph";
File jobGraphFile = new File(jobGraphFilename);
try (FileOutputStream output = new FileOutputStream(jobGraphFile);
ObjectOutputStream obOutput = new ObjectOutputStream(output)){
obOutput.writeObject(jobGraph);
}

通常你可以像下面这样全部通过启动参数来传入配置:

1
2
3
4
5
6
7
8
9
bin/mesos-appmaster.sh \
-Dmesos.master=master.foobar.org:5050 \
-Djobmanager.heap.mb=1024 \
-Djobmanager.rpc.port=6123 \
-Drest.port=8081 \
-Dmesos.resourcemanager.tasks.mem=4096 \
-Dtaskmanager.heap.mb=3500 \
-Dtaskmanager.numberOfTaskSlots=2 \
-Dparallelism.default=10

更多关于 Flink On Mesos 的安装以及配置可以访问 官网 查看。

Kubernetes

Kubernetes(k8s)是 Google 开源的容器集群管理系统,在 Docker 技术的基础上,为容器化的应用提供部署运行、资源调度、服务发现和动态伸缩等一系列完整功能,提高了大规模容器集群管理的便捷性。它是一个完备的分布式系统支撑平台,具有完备的集群管理能力,多层次的安全防护和准入机制、多租户应用支撑能力、透明的服务注册和发现机制、內建智能负载均衡器、强大的故障发现和自我修复能力、服务滚动升级和在线扩容能力、可扩展的资源自动调度机制以及多粒度的资源配额管理能力。同时 Kubernetes 提供完善的管理工具,涵盖了包括开发、部署测试、运维监控在内的各个环节。

因为 Kubernetes 的好处这么多,所以现在好多公司也开始使用 Kubernetes,那么 Flink 也有必要支持部署在 Kubernetes 上,在官方文档里面也介绍了两种部署的方法:

  • Flink session cluster
  • Flink job cluster

下面我演示一下如何部署 Flink session cluster 在 K8s 上。首先你需要创建 jobmanager-service、jobmanager-deployment、taskmanager-deployment,在利用 kubectl 命令创建之前你需要分别创建这几个 yaml 文件。

  • jobmanager-service.yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
apiVersion: v1
kind: Service
metadata:
name: flink-jobmanager
spec:
ports:
- name: rpc
port: 6123
- name: blob
port: 6124
- name: query
port: 6125
- name: ui
port: 8081
selector:
app: flink
component: jobmanager
  • jobmanager-deployment.yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
name: flink-jobmanager
spec:
replicas: 1
template:
metadata:
labels:
app: flink
component: jobmanager
spec:
containers:
- name: jobmanager
image: flink:latest
args:
- jobmanager
ports:
- containerPort: 6123
name: rpc
- containerPort: 6124
name: blob
- containerPort: 6125
name: query
- containerPort: 8081
name: ui
env:
- name: JOB_MANAGER_RPC_ADDRESS
value: flink-jobmanager
  • taskmanager-deployment.yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
name: flink-taskmanager
spec:
replicas: 2
template:
metadata:
labels:
app: flink
component: taskmanager
spec:
containers:
- name: taskmanager
image: flink:latest
args:
- taskmanager
ports:
- containerPort: 6121
name: data
- containerPort: 6122
name: rpc
- containerPort: 6125
name: query
env:
- name: JOB_MANAGER_RPC_ADDRESS
value: flink-jobmanager

创建好这三个文件后,分别执行下面三个命令:

1
2
3
4
5
kubectl create -f jobmanager-service.yaml

kubectl create -f jobmanager-deployment.yaml

kubectl create -f taskmanager-deployment.yaml

undefined

然后去 K8s 的 Dashboard 上面查看 Flink 的情况:

undefined

我们如果要看 Flink 自带的 UI 的话需要将端口映射一下,使用如下命令:

1
kubectl port-forward service/flink-jobmanager 8081:8081

undefined

然后访问 http://localhost:8081 就可以看到 Flink 自带的 UI 了:

undefined

如果我们要提交 Job 的话,我们先用命令行来操作一下:

1
./bin/flink run -d -m localhost:8081 ~/word-count.jar

执行完命令后的话,就可以去页面看到刚才提交的 Job 了:

undefined

另外你也可以通过 Flink UI 上传 Jar 包把 Job run 起来。这里再对上面这几个配置文件进行讲解:

undefined

undefined

undefined

启动完了之后的话,如果你想删除就需要使用下面命令:

1
2
3
4
5
kubectl delete -f jobmanager-deployment.yaml

kubectl delete -f taskmanager-deployment.yaml

kubectl delete -f jobmanager-service.yaml

undefined

小结与反思

这部分介绍了下 Flink 的所有配置文件及其配置文件中的参数的作用,然后讲解了 Flink 的多种部署方式,比如 Standalone、YARN、Mesos、Kubernetes。每种方式的差异性还不小,如果你们公司没有使用类似 YARN、Mesoss、K8S 等分布式调度系统,那么只好使用 Standalone 的 Flink 集群了,这种模式比较简单,可以直接使用 Flink 自带的 UI 上传作业 Jar 包,不像和 YARN、K8S 这种一样会有多种方式供你选择。根据平时在社群里面的答疑,目前将 Flink 部署在 YARN 上的是非常多的,Flink on K8S 好多公司也处于在调研阶段。其实具体该选择哪种方式运行 Flink,最主要还是得看自己公司的架构选型和允许分配的资源(人力 & 机器资源 & 作业的数量)。

附:Confluence 上有个 FLIP 讲 Flink 的部署,感兴趣可以看看。

当将 Flink Job Manager、Task Manager 都运行起来了,并且也部署了不少 Flink Job,那么它到底是否还在运行、运行的状态如何、资源 Task Manager 和 Slot 的个数是否足够、Job 内部是否出现异常、计算速度是否跟得上数据生产的速度 等这些问题其实对我们来说是比较关注的,所以就很迫切的需要一个监控系统帮我们把整个 Flink 集群的运行状态给展示出来。通过监控系统我们能够很好的知道 Flink 内部的整个运行状态,然后才能够根据项目生产环境遇到的问题 ‘对症下药’。下面分别来讲下 Job Manager、TaskManager、Flink Job 的监控以及最关心的一些监控指标。

监控 Job Manager

我们知道 Job Manager 是 Flink 集群的中控节点,类似于 Apache Storm 的 Nimbus 以及 Apache Spark 的 Driver 的角色。它负责作业的调度、作业 Jar 包的管理、Checkpoint 的协调和发起、与 Task Manager 之间的心跳检查等工作。如果 Job Manager 出现问题的话,就会导致作业 UI 信息查看不了,Task Manager 和所有运行的作业都会受到一定的影响,所以这也是为啥在 7.1 节中强调 Job Manager 的高可用问题。

在 Flink 自带的 UI 上 Job Manager 那个 Tab 展示的其实并没有显示其对应的 Metrics,那么对于 Job Manager 来说常见比较关心的监控指标有哪些呢?

基础指标

因为 Flink Job Manager 其实也是一个 Java 的应用程序,那么它自然也会有 Java 应用程序的指标,比如内存、CPU、GC、类加载、线程信息等。

  • 内存:内存又分堆内存和非堆内存,在 Flink 中还有 Direct 内存,每种内存又有初始值、使用值、最大值等指标,因为在 Job Manager 中的工作其实相当于 Task Manager 来说比较少,也不存储事件数据,所以通常 Job Manager 占用的内存不会很多,在 Flink Job Manager 中自带的内存 Metrics 指标有:
1
2
3
4
5
6
7
8
9
10
11
12
jobmanager_Status_JVM_Memory_Direct_Count
jobmanager_Status_JVM_Memory_Direct_MemoryUsed
jobmanager_Status_JVM_Memory_Direct_TotalCapacity
jobmanager_Status_JVM_Memory_Heap_Committed
jobmanager_Status_JVM_Memory_Heap_Max
jobmanager_Status_JVM_Memory_Heap_Used
jobmanager_Status_JVM_Memory_Mapped_Count
jobmanager_Status_JVM_Memory_Mapped_MemoryUsed
jobmanager_Status_JVM_Memory_Mapped_TotalCapacity
jobmanager_Status_JVM_Memory_NonHeap_Committed
jobmanager_Status_JVM_Memory_NonHeap_Max
jobmanager_Status_JVM_Memory_NonHeap_Used
  • CPU:Job Manager 分配的 CPU 使用情况,如果使用类似 K8S 等资源调度系统,则需要对每个容器进行设置资源,比如 CPU 限制不能超过多少,在 Flink Job Manager 中自带的 CPU 指标有:
1
2
jobmanager_Status_JVM_CPU_Load
jobmanager_Status_JVM_CPU_Time
  • GC:GC 信息对于 Java 应用来说是避免不了的,每种 GC 都有时间和次数的指标可以供参考,提供的指标有:
1
2
3
4
jobmanager_Status_JVM_GarbageCollector_PS_MarkSweep_Count
jobmanager_Status_JVM_GarbageCollector_PS_MarkSweep_Time
jobmanager_Status_JVM_GarbageCollector_PS_Scavenge_Count
jobmanager_Status_JVM_GarbageCollector_PS_Scavenge_Time

Checkpoint 指标

因为 Job Manager 负责了作业的 Checkpoint 的协调和发起功能,所以 Checkpoint 相关的指标就有表示 Checkpoint 执行的时间、Checkpoint 的时间长短、完成的 Checkpoint 的次数、Checkpoint 失败的次数、Checkpoint 正在执行 Checkpoint 的个数等,其对应的指标如下:

1
2
3
4
5
6
7
8
9
jobmanager_job_lastCheckpointAlignmentBuffered
jobmanager_job_lastCheckpointDuration
jobmanager_job_lastCheckpointExternalPath
jobmanager_job_lastCheckpointRestoreTimestamp
jobmanager_job_lastCheckpointSize
jobmanager_job_numberOfCompletedCheckpoints
jobmanager_job_numberOfFailedCheckpoints
jobmanager_job_numberOfInProgressCheckpoints
jobmanager_job_totalNumberOfCheckpoints

重要的指标

另外还有比较重要的指标就是 Flink UI 上也提供的,类似于 Slot 总共个数、Slot 可使用的个数、Task Manager 的个数(通过查看该值可以知道是否有 Task Manager 发生异常重启)、正在运行的作业数量、作业运行的时间和完成的时间、作业的重启次数,对应的指标如下:

1
2
3
4
5
6
7
8
jobmanager_job_uptime
jobmanager_numRegisteredTaskManagers
jobmanager_numRunningJobs
jobmanager_taskSlotsAvailable
jobmanager_taskSlotsTotal
jobmanager_job_downtime
jobmanager_job_fullRestarts
jobmanager_job_restartingTime

监控 Task Manager

Task Manager 在 Flink 集群中也是一个个的进程实例,它的数量代表着能够运行作业个数的能力,所有的 Flink 作业最终其实是会在 Task Manager 上运行的,Task Manager 管理着运行在它上面的所有作业的 Task 的整个生命周期,包括了 Task 的启动销毁、内存管理、磁盘 IO、网络传输管理等。

因为所有的 Task 都是运行运行在 Task Manager 上的,有的 Task 可能会做比较复杂的操作或者会存储很多数据在内存中,那么就会消耗很大的资源,所以通常来说 Task Manager 要比 Job Manager 消耗的资源要多,但是这个资源具体多少其实也不好预估,所以可能会出现由于分配资源的不合理,导致 TaskManager 出现 OOM 等问题。一旦 TaskManager 因为各种问题导致崩溃重启的话,运行在它上面的 Task 也都会失败,Job Manager 与它的通信也会丢失。因为作业出现 failover,所以在重启这段时间它是不会去消费数据的,所以必然就会出现数据消费延迟的问题。对于这种情况那么必然就很需要 TaskManager 的监控信息,这样才能够对整个集群的 TaskManager 做一个提前预警。

那么在 Flink 中自带的 Task Manager Metrics 有哪些呢?主要也是 CPU、类加载、GC、内存、网络等。其实这些信息在 Flink UI 上也是有,不知道读者有没有细心观察过。

undefined

在这个 Task Manager 的 Metrics 监控页面通常比较关心的指标有内存相关的,还有就是 GC 的指标,通常一个 Task Manager 出现 OOM 之前会不断的进行 GC,在这个 Metrics 页面它展示了年轻代和老年代的 GC 信息(时间和次数),大家可以细心观察下是否 Task Manager OOM 前老年代和新生代的 GC 次数比较、时间比较长。

undefined

在 Flink Reporter 中提供的 Task Manager Metrics 指标如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
taskmanager_Status_JVM_CPU_Load
taskmanager_Status_JVM_CPU_Time
taskmanager_Status_JVM_ClassLoader_ClassesLoaded
taskmanager_Status_JVM_ClassLoader_ClassesUnloaded
taskmanager_Status_JVM_GarbageCollector_G1_Old_Generation_Count
taskmanager_Status_JVM_GarbageCollector_G1_Old_Generation_Time
taskmanager_Status_JVM_GarbageCollector_G1_Young_Generation_Count
taskmanager_Status_JVM_GarbageCollector_G1_Young_Generation_Time
taskmanager_Status_JVM_Memory_Direct_Count
taskmanager_Status_JVM_Memory_Direct_MemoryUsed
taskmanager_Status_JVM_Memory_Direct_TotalCapacity
taskmanager_Status_JVM_Memory_Heap_Committed
taskmanager_Status_JVM_Memory_Heap_Max
taskmanager_Status_JVM_Memory_Heap_Used
taskmanager_Status_JVM_Memory_Mapped_Count
taskmanager_Status_JVM_Memory_Mapped_MemoryUsed
taskmanager_Status_JVM_Memory_Mapped_TotalCapacity
taskmanager_Status_JVM_Memory_NonHeap_Committed
taskmanager_Status_JVM_Memory_NonHeap_Max
taskmanager_Status_JVM_Memory_NonHeap_Used
taskmanager_Status_JVM_Threads_Count
taskmanager_Status_Network_AvailableMemorySegments
taskmanager_Status_Network_TotalMemorySegments
taskmanager_Status_Shuffle_Netty_AvailableMemorySegments
taskmanager_Status_Shuffle_Netty_TotalMemorySegments

对于运行的作业来说,其实我们会更关心其运行状态,如果没有其对应的一些监控信息,那么对于我们来说这个 Job 就是一个黑盒,完全不知道是否在运行,Job 运行状态是什么、Task 运行状态是什么、是否在消费数据、消费数据是咋样(细分到每个 Task)、消费速度能否跟上生产数据的速度、处理数据的过程中是否有遇到什么错误日志、处理数据是否有出现反压问题等等。

上面列举的这些问题通常来说是比较关心的,那么在 Flink UI 上也是有提供的查看对应的信息的,点开对应的作业就可以查看到作业的执行图,每个 Task 的信息都是会展示出来的,包含了状态、Bytes Received(接收到记录的容量大小)、Records Received(接收到记录的条数)、Bytes Sent(发出去的记录的容量大小)、Records Sent(发出去记录的条数)、异常信息、timeline(作业运行状态的时间线)、Checkpoint 信息。

undefined

这些指标也可以通过 Flink 的 Reporter 进行上报存储到第三方的时序数据库,然后通过类似 Grafana 展示出来。通过这些信息大概就可以清楚的知道一个 Job 的整个运行状态,然后根据这些运行状态去分析作业是否有问题。

undefined

在流作业中最关键的指标无非是作业的实时性,那么延迟就是衡量作业的是否实时的一个基本参数,但是对于现有的这些信息其实还不知道作业的消费是否有延迟,通常来说可以结合 Kafka 的监控去查看对应消费的 Topic 的 Group 的 Lag 信息,如果 Lag 很大就表明有数据堆积了,另外还有一个办法就是需要自己在作业中自定义 Metrics 做埋点,将算子在处理数据的系统时间与数据自身的 Event Time 做一个差值,求得值就可以知道算子消费的数据是什么时候的了。比如在 1571457964000(2019-10-19 12:06:04)Map 算子消费的数据的事件时间是 1571457604000(2019-10-19 12:00:04),相差了 6 分钟,那么就表明消费延迟了 6 分钟,然后通过 Metrics Reporter 将埋点的 Metrics 信息上传,这样最终就可以获取到作业在每个算子处的消费延迟的时间。

上面的是针对于作业延迟的判断方法,另外像类似于作业反压的情况,在 Flink 的 UI 也会有展示,具体怎么去分析和处理这种问题在 9.1 节中有详细讲解。

根据这些监控信息不仅可以做到提前预警,做好资源的扩容(比如增加容器的数量/内存/CPU/并行度/Slot 个数),也还可以找出作业配置的资源是否有浪费。通常来说一个作业的上线可能是会经过资源的预估,然后才会去申请这个作业要配置多少资源,比如算子要使用多少并行度,最后上线后可以通过完整的运行监控信息查看该作业配置的并行度是否有过多或者配置的内存比较大。比如出现下面这些情况的时候可能就是资源出现浪费了:

  • 作业消费从未发生过延迟,即使在数据流量高峰的时候,也未发生过消费延迟
  • 作业运行所在的 Task Manager 堆内存使用率异常的低
  • 作业运行所在的 Task Manager 的 GC 时间和次数非常规律,没有出现异常的现象

undefined

在 Flink Metrics Reporter 上传的指标中大概有下面这些:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
taskmanager_job_task_Shuffle_Netty_Input_Buffers_outPoolUsage
taskmanager_job_task_Shuffle_Netty_Input_Buffers_outputQueueLength
taskmanager_job_task_Shuffle_Netty_Output_Buffers_inPoolUsage
taskmanager_job_task_Shuffle_Netty_Output_Buffers_inputExclusiveBuffersUsage
taskmanager_job_task_Shuffle_Netty_Output_Buffers_inputFloatingBuffersUsage
taskmanager_job_task_Shuffle_Netty_Output_Buffers_inputQueueLength
taskmanager_job_task_Shuffle_Netty_Output_numBuffersInLocal
taskmanager_job_task_Shuffle_Netty_Output_numBuffersInLocalPerSecond
taskmanager_job_task_Shuffle_Netty_Output_numBuffersInRemote
taskmanager_job_task_Shuffle_Netty_Output_numBuffersInRemotePerSecond
taskmanager_job_task_Shuffle_Netty_Output_numBytesInLocal
taskmanager_job_task_Shuffle_Netty_Output_numBytesInLocalPerSecond
taskmanager_job_task_Shuffle_Netty_Output_numBytesInRemote
taskmanager_job_task_Shuffle_Netty_Output_numBytesInRemotePerSecond
taskmanager_job_task_buffers_inPoolUsage
taskmanager_job_task_buffers_inputExclusiveBuffersUsage
taskmanager_job_task_buffers_inputFloatingBuffersUsage
taskmanager_job_task_buffers_inputQueueLength
taskmanager_job_task_buffers_outPoolUsage
taskmanager_job_task_buffers_outputQueueLength
taskmanager_job_task_checkpointAlignmentTime
taskmanager_job_task_currentInputWatermark
taskmanager_job_task_numBuffersInLocal
taskmanager_job_task_numBuffersInLocalPerSecond
taskmanager_job_task_numBuffersInRemote
taskmanager_job_task_numBuffersInRemotePerSecond
taskmanager_job_task_numBuffersOut
taskmanager_job_task_numBuffersOutPerSecond
taskmanager_job_task_numBytesIn
taskmanager_job_task_numBytesInLocal
taskmanager_job_task_numBytesInLocalPerSecond
taskmanager_job_task_numBytesInPerSecond
taskmanager_job_task_numBytesInRemote
taskmanager_job_task_numBytesInRemotePerSecond
taskmanager_job_task_numBytesOut
taskmanager_job_task_numBytesOutPerSecond
taskmanager_job_task_numRecordsIn
taskmanager_job_task_numRecordsInPerSecond
taskmanager_job_task_numRecordsOut
taskmanager_job_task_numRecordsOutPerSecond
taskmanager_job_task_operator_currentInputWatermark
taskmanager_job_task_operator_currentOutputWatermark
taskmanager_job_task_operator_numLateRecordsDropped
taskmanager_job_task_operator_numRecordsIn
taskmanager_job_task_operator_numRecordsInPerSecond
taskmanager_job_task_operator_numRecordsOut
taskmanager_job_task_operator_numRecordsOutPerSecond

最关心的监控指标有哪些

上面已经提及到 Flink 的 Job Manager、Task Manager 和运行的 Flink Job 的监控以及常用的监控信息,这些指标有的是可以直接在 Flink 的 UI 上观察到的,另外 Flink 提供了 Metrics Reporter 进行上报存储到监控系统中去,然后通过可视化的图表进行展示,在 8.2 节中将教大家如何构建一个完整的监控系统。那么有了这么多监控指标,其实哪些是比较重要的呢,比如说这些指标出现异常的时候可以发出告警及时进行通知,这样可以做到预警作用,另外还可以根据这些信息进行作业资源的评估。下面列举一些笔者觉得比较重要的指标:

Job Manager

在 Job Manager 中有着该集群中所有的 Task Manager 的个数、Slot 的总个数、Slot 的可用个数、运行的时间、作业的 Checkpoint 情况,笔者觉得这几个指标可以重点关注。

  • TaskManager 个数:如果出现 TaskManager 突然减少,可能是因为有 TaskManager 挂掉重启,一旦该 TaskManager 之前运行了很多作业,那么重启带来的影响必然是巨大的。
  • Slot 个数:取决于 TaskManager 的个数,决定了能运行作业的最大并行度,如果资源不够,及时扩容。
  • 作业运行时间:根据作业的运行时间来判断作业是否存活,中途是否掉线过。
  • Checkpoint 情况:Checkpoint 是 Job Manager 发起的,并且关乎到作业的状态是否可以完整的保存。

TaskManager

因为所有的作业最终都是运行在 TaskManager 上,所以 TaskManager 的监控指标也是异常的监控,并且作业的复杂度也会影响 TaskManager 的资源使用情况,所以 TaskManager 的基础监控指标比如内存、GC 如果出现异常或者超出设置的阈值则需要立马进行告警通知,防止后面导致大批量的作业出现故障重启。

  • 内存使用率:部分作业的算子会将所有的 State 数据存储在内存中,这样就会导致 TaskManager 的内存使用率会上升,还有就是可以根据该指标看作业的利用率,从而最后来重新划分资源的配置。
  • GC 情况:分时间和次数,一旦 TaskManager 的内存率很高的时候,必定伴随着频繁的 GC,如果在 GC 的时候没有得到及时的预警,那么将面临 OOM 风险。

作业的稳定性和及时性其实就是大家最关心的,常见的指标有:作业的状态、Task 的状态、作业算子的消费速度、作业出现的异常日志。

  • 作业的状态:在 UI 上是可以看到作业的状态信息,常见的状态变更信息如下图。

undefined

Task 的状态:其实导致作业的状态发生变化的原因通常是由于 Task 的运行状态出现导致,所以也需要对 Task 的运行状态进行监控,Task 的运行状态如下图。

undefined

  • 作业异常日志:导致 Task 出现状态异常的根因通常是作业中的代码出现各种各样的异常日志,最后可能还会导致作业无限重启,所以作业的异常日志也是需要及时关注。
  • 作业重启次数:当 Task 状态和作业的状态发生变化的时候,如果作业中配置了重启策略或者开启了 Checkpoint 则会进行作业重启的,重启作业的带来的影响也会很多,并且会伴随着一些不确定的因素,最终导致作业一直重启,这样既不能解决问题,还一直在占用着资源的消耗。
  • 算子的消费速度:代表了作业的消费能力,还可以知道作业是否发生延迟,可以包含算子接收的数据量和发出去数据量,从而可以知道在算子处是否有发生数据的丢失。

小结与反思

本节讲了 Flink 中常见的监控对象,比如 Job Manager、Task Manager 和 Flink Job,对于这几个分别介绍了其内部大概有的监控指标,以及在真实生产环境关心的指标,你是否还有其他的监控指标需要补充呢?

本节涉及的监控指标对应的含义可以参考官网链接:https://ci.apache.org/projects/flink/flink-docs-stable/monitoring/metrics.html#system-metrics

本节涉及的监控指标列表地址:https://github.com/zhisheng17/flink-learning/blob/master/flink-learning-monitor/flink*monitor*measurements.md

8.1 节中讲解了 Job Manager、Task Manager 和 Flink Job 的监控,以及需要关注的监控指标有哪些。本节带大家讲解一下如何搭建一套完整的 Flink 监控系统,如果你所在的公司没有专门的监控平台,那么可以根据本节的内容来为公司搭建一套属于自己公司的 Flink 监控系统。

利用 API 获取监控数据

熟悉 Flink 的朋友都知道 Flink 的 UI 上面已经详细地展示了很多监控指标的数据,并且这些指标还是比较重要的,所以如果不想搭建额外的监控系统,那么直接利用 Flink 自身的 UI 就可以获取到很多重要的监控信息。这里要讲的是这些监控信息其实也是通过 Flink 自身的 Rest API 来获取数据的,所以其实要搭建一个粗糙的监控平台,也是可以直接利用现有的接口定时去获取数据,然后将这些指标的数据存储在某种时序数据库中,最后用些可视化图表做个展示,这样一个完整的监控系统就做出来了。

这里通过 Chrome 浏览器的控制台来查看一下有哪些 REST API 是用来提供监控数据的。

1.在Chrome 浏览器中打开 http://localhost:8081/overview 页面,可以获取到整个 Flink 集群的资源信息:TaskManager 个数(Task Managers)、Slot 总个数(Total Task Slots)、可用 Slot 个数(Available Task Slots)、Job 运行个数(Running Jobs)、Job 运行状态(Finished 0 Canceled 0 Failed 0)等,如下图所示。

undefined

2.通过 http://localhost:8081/taskmanagers 页面查看 Task Manager 列表,可以知道该集群下所有 Task Manager 的信息(数据端口号(Data Port)、上一次心跳时间(Last Heartbeat)、总共的 Slot 个数(All Slots)、空闲的 Slot 个数(Free Slots)、以及 CPU 和内存的分配使用情况,如下图所示。

undefined

3.通过 http://localhost:8081/taskmanagers/tm_id 页面查看 Task Manager 的具体情况(这里的 tm_id 是个随机的 UUID 值)。在这个页面上,除了上一条的监控信息可以查看,还可以查看该 Task Manager 的 JVM(堆和非堆)、Direct 内存、网络、GC 次数和时间。内存和 GC 这些信息非常重要,很多时候 Task Manager 频繁重启的原因就是 JVM 内存设置得不合理,导致频繁的 GC,最后使得 OOM 崩溃,不得不重启。

undefined

另外如果你在 /taskmanagers/tm_id 接口后面加个 /log 就可以查看该 Task Manager 的日志,注意,在 Flink 中的日志和平常自己写的应用中的日志是不一样的。在 Flink 中,日志是以 Task Manager 为概念打印出来的,而不是以单个 Job 打印出来的,如果你的 Job 在多个 Task Manager 上运行,那么日志就会在多个 Task Manager 中打印出来。如果一个 Task Manager 中运行了多个 Job,那么它里面的日志就会很混乱,查看日志时会发现它为什么既有这个 Job 打出来的日志,又有那个 Job 打出来的日志,如果你之前有这个疑问,那么相信你看完这里,就不会有疑问了。

对于这种设计是否真的好,不同的人有不同的看法,在 Flink 的 Issue 中就有人提出了该问题,Issue 中的描述是希望日志可以是 Job 与 Job 之间的隔离,这样日志更方便采集和查看,对于排查问题也会更快。对此国内有公司也对这一部分做了改进,不知道正在看本书的你是否有什么好的想法可以解决 Flink 的这一痛点。

4.通过 http://localhost:8081/#/job-manager/config 页面可以看到可 Job Manager 的配置信息,另外通过 http://localhost:8081/jobmanager/log 页面可以查看 Job Manager 的日志详情。

5.通过 http://localhost:8081/jobs/job_id 页面可以查看 Job 的监控数据,由于指标(包括了 Job 的 Task 数据、Operator 数据、Exception 数据、Checkpoint 数据等)过多,大家可以自己在本地测试查看。

undefined

上面列举了几个 REST API(不是全部),主要是为了告诉大家,其实这些接口我们都知道,那么我们也可以利用这些接口去获取对应的监控数据,然后绘制出更酷炫的图表,用更直观的页面将这些数据展示出来,这样就能更好地控制。

除了利用 Flink UI 提供的接口去定时获取到监控数据,其实 Flink 还提供了很多的 reporter 去上报监控数据,比如 JMXReporter、PrometheusReporter、PrometheusPushGatewayReporter、InfluxDBReporter、StatsDReporter 等,这样就可以根据需求去定制获取到 Flink 的监控数据,下面教大家使用几个常用的 reporter。

相关 Rest API 可以查看官网链接:https://ci.apache.org/projects/flink/flink-docs-stable/monitoring/metrics.html#rest-api-integration

Metrics 类型介绍

可以在继承自 RichFunction 的函数中通过 getRuntimeContext().getMetricGroup() 获取 Metric 信息,常见的 Metrics 的类型有 Counter、Gauge、Histogram、Meter。

Counter

Counter 用于计数,当前值可以使用 inc()/inc(long n) 递增和 dec()/dec(long n) 递减,在实现 RichFunction 中的函数的 open 方法注册 Counter。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private transient Counter counter;

@Override
public void open(Configuration config) {
this.counter = getRuntimeContext()
.getMetricGroup()
.counter("zhisheng_counter");
}

//或者自定义 Counter
@Override
public void open(Configuration config) {
this.counter = getRuntimeContext()
.getMetricGroup()
.counter("zhisheng_counter", new CustomCounter());
}

@Override
public String map(String value) throws Exception {
this.counter.inc();
return value;
}

Gauge

Gauge 根据需要提供任何类型的值,要使用 Gauge 的话,需要实现 Gauge 接口,返回值没有规定类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private transient int valueToExpose = 0;

@Override
public void open(Configuration config) {
getRuntimeContext()
.getMetricGroup()
.gauge("zhisheng_gauge", new Gauge<Integer>() {
@Override
public Integer getValue() {
return valueToExpose;
}
});
}

@Override
public String map(String value) throws Exception {
valueToExpose++;
return value;
}

Histogram

Histogram 统计数据的分布情况,比如最小值,最大值,中间值,还有分位数等。使用情况如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private transient Histogram histogram;

@Override
public void open(Configuration config) {
this.histogram = getRuntimeContext()
.getMetricGroup()
.histogram("zhisheng_histogram", new MyHistogram());
}

@Override
public Long map(Long value) throws Exception {
this.histogram.update(value);
return value;
}

Meter

Meter 代表平均吞吐量,使用情况如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private transient Meter meter;

@Override
public void open(Configuration config) {
this.meter = getRuntimeContext()
.getMetricGroup()
.meter("myMeter", new MyMeter());
}

@Override
public Long map(Long value) throws Exception {
this.meter.markEvent();
return value;
}

利用 JMXReporter 获取监控数据

JMX 对于大家来说应该不太陌生,在 Flink 中默认提供了 JMXReporter 获取到监控数据,不需要额外添加依赖项,但是需要在 flink-conf.yaml 配置文件中加入如下配置即可开启 JMX:

1
2
metrics.reporter.jmx.factory.class: org.apache.flink.metrics.jmx.JMXReporterFactory
metrics.reporter.jmx.port: 8789

然后利用 JDK 自带的 jconsole 可以查看 MBean 信息。

undefined

undefined

如下图所示,你可以看到左侧是有很多的监控指标,如果点进去是可以查看到每个指标对应的 value 值。

undefined

但是你有没有发现这些指标只有 Job Manager 的监控指标,没有 Task Manager 的监控指标,如果你在同一台服务器上面既运行了 Job Manager,又运行了 Task Manager,那么只开启一个端口号那么是只能够监听到一个的数据,如果你要监听多个数据,那么就需要在端口设置里填写一个范围(这里需要特别注意一下),具体配置如下:

1
2
3
# jmx reporter
metrics.reporter.jmx.factory.class: org.apache.flink.metrics.jmx.JMXReporterFactory
metrics.reporter.jmx.port: 8789-8799

这样就表示监听了多个端口(从 8789 ~ 8799),那么再通过 jconsole 连接 8790 端口就会出现 Task Manager 的监控指标数据了。

undefined

查看日志也可以看到开启 JMX 成功的日志,如下所示。

1
2
3
4
2019-10-07 10:52:51,839 INFO  org.apache.flink.metrics.jmx.JMXReporter                      - Started JMX server on port 8789.
2019-10-07 10:52:51,839 INFO org.apache.flink.metrics.jmx.JMXReporter - Configured JMXReporter with {port:8789-8799}
2019-10-07 10:52:51,840 INFO org.apache.flink.runtime.metrics.ReporterSetup - Configuring jmx with {factory.class=org.apache.flink.metrics.jmx.JMXReporterFactory, port=8789-8799}.
2019-10-07 10:52:51,841 INFO org.apache.flink.runtime.metrics.MetricRegistryImpl - Reporting metrics for reporter jmx of type org.apache.flink.metrics.jmx.JMXReporter.

利用 PrometheusReporter 获取监控数据

要使用该 reporter 的话,需要将 opt 目录下的 flink-metrics-prometheus-1.9.0.jar 依赖放到 lib 目录下,可以配置的参数有:

  • port:该参数为可选项,Prometheus 监听的端口,默认是 9249,和上面使用 JMXReporter 一样,如果是在一台服务器上既运行了 Job Manager,又运行了 TaskManager,则使用端口范围,比如 9249-9259
  • filterLabelValueCharacters:该参数为可选项,表示指定是否过滤标签值字符,如果开启,则删除所有不匹配 [a-zA-Z0-9:_] 的字符,否则不会删除任何字符。

除了上面两个可选参数,另外一个参数是必须要在 flink-conf.yaml 中配置的,那就是 metrics reporter class。比如像下面这样配置:

1
metrics.reporter.prom.class: org.apache.flink.metrics.prometheus.PrometheusReporter

Flink 中的 metrics 类型和 Prometheus 中 metrics 类型对比如下:

undefined

利用 PrometheusPushGatewayReporter 获取监控数据

PushGateway 是 Prometheus 生态中一个重要工具,使用它的原因主要是:

  • Prometheus 采用 pull 模式,可能由于 Prometheus 和其他 target 对象不在一个子网或者防火墙原因,导致 Prometheus 无法直接拉取各个 target 数据。
  • 在监控业务数据的时候,需要将不同数据汇总, 由 Prometheus 统一收集。

那么使用 PrometheusPushGatewayReporter 的话,该 reporter 会定时将 metrics 数据推送到 PushGateway,然后再由 Prometheus 去拉取这些 metrics 数据。如果使用 PrometheusPushGatewayReporter 收集数据的话,也是需要将 opt 目录下的 flink-metrics-prometheus-1.9.0.jar 依赖放到 lib 目录下的,可配置的参数有:

  • deleteOnShutdown:默认值是 true,表示是否在关闭时从 PushGateway 删除指标。
  • filterLabelValueCharacters:默认值是 true,表示是否过滤标签值字符,如果开启,则不符合 [a-zA-Z0-9:_] 的字符都将被删除。
  • host:无默认值,配置 PushGateway 服务所在的机器 IP。
  • jobName:无默认值,要上报 Metrics 的 Job 名称。
  • port:默认值是 -1,这里配置 PushGateway 服务的端口。
  • randomJobNameSuffix:默认值是 true,指定是否将随机后缀名附加到作业名。

在 flink-conf.yaml 中配置的样例如下:

1
2
3
4
5
6
metrics.reporter.promgateway.class: org.apache.flink.metrics.prometheus.PrometheusPushGatewayReporter
metrics.reporter.promgateway.host: localhost
metrics.reporter.promgateway.port: 9091
metrics.reporter.promgateway.jobName: zhisheng
metrics.reporter.promgateway.randomJobNameSuffix: true
metrics.reporter.promgateway.deleteOnShutdown: false

利用 InfluxDBReporter 获取监控数据

Flink 里面提供了 InfluxDBReporter 支持将 Flink 的 metrics 数据直接存储到 InfluxDB 中,在源码中该模块是通过 MetricMapper 类将 MeasurementInfo(这个类是 metric 的数据结构,里面含有两个字段 name 和 tags) 和 Gauge、Counter、Histogram、Meter 组装成 InfluxDB 中的 Point 数据,Point 结构如下(主要就是构造 metric name、fields、tags 和 timestamp):

1
2
3
4
5
private String measurement;
private Map<String, String> tags;
private Long time;
private TimeUnit precision;
private Map<String, Object> fields;

然后在 InfluxdbReporter 类中将 metric 数据导入 InfluxDB,该类继承自 AbstractReporter 抽象类,实现了 Scheduled 接口,有下面 3 个属性:

1
2
3
private String database;
private String retentionPolicy;
private InfluxDB influxDB;

在 open 方法中获取配置文件中的 InfluxDB 设置,然后初始化 InfluxDB 相关的配置,构造 InfluxDB 客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public void open(MetricConfig config) {
//获取到 host 和 port
String host = getString(config, HOST);
int port = getInteger(config, PORT);
//判断 host 和 port 是否合法
if (!isValidHost(host) || !isValidPort(port)) {
throw new IllegalArgumentException("Invalid host/port configuration. Host: " + host + " Port: " + port);
}
//获取到 InfluxDB database
String database = getString(config, DB);
if (database == null) {
throw new IllegalArgumentException("'" + DB.key() + "' configuration option is not set");
}
String url = String.format("http://%s:%d", host, port);
//获取到 InfluxDB username 和 password
String username = getString(config, USERNAME);
String password = getString(config, PASSWORD);

this.database = database;
//InfluxDB 保留政策
this.retentionPolicy = getString(config, RETENTION_POLICY);
if (username != null && password != null) {
//如果有用户名和密码,根据 url 和 用户名密码来创建连接
influxDB = InfluxDBFactory.connect(url, username, password);
} else {
//否则就根据 url 连接
influxDB = InfluxDBFactory.connect(url);
}

log.info("Configured InfluxDBReporter with {host:{}, port:{}, db:{}, and retentionPolicy:{}}", host, port, database, retentionPolicy);
}

然后在 report 方法中调用一个内部 buildReport 方法来构造 BatchPoints,将一批 Point 放在该对象中,BatchPoints 对象的属性如下:

1
2
3
4
5
6
private String database;
private String retentionPolicy;
private Map<String, String> tags;
private List<Point> points;
private ConsistencyLevel consistency;
private TimeUnit precision;

通过 buildReport 方法返回的 BatchPoints 如果不为空,则会通过 write 方法将 BatchPoints 写入 InfluxDB:

1
2
3
if (report != null) {
influxDB.write(report);
}

在使用 InfluxDBReporter 时需要注意:

1.必须复制 Flink 安装目录下的 /opt/flink-metrics-influxdb-1.9.0.jar 到 flink 的 lib 目录下,否则运行起来会报错如下:

undefined

2.如下所示,在 flink-conf.yaml 中添加 InfluxDB 相关的配置。

1
2
3
4
5
6
7
metrics.reporter.influxdb.class:org.apache.flink.metrics.influxdb.InfluxdbReporter
metrics.reporter.influxdb.host:localhost # InfluxDB服务器主机
metrics.reporter.influxdb.port: 8086 # 可选)InfluxDB 服务器端口,默认为 8086
metrics.reporter.influxdb.db:zhisheng # 用于存储指标的 InfluxDB 数据库
metrics.reporter.influxdb.username:zhisheng # (可选)用于身份验证的 InfluxDB 用户名
metrics.reporter.influxdb.password:123456 # (可选)InfluxDB 用户名用于身份验证的密码
metrics.reporter.influxdb.retentionPolicy: one_hour #(可选)InfluxDB 数据保留策略,默认为服务器上数据库定义的保留策略

如果填错了密码会报鉴权失败的错误:

undefined

安装 InfluxDB 和 Grafana

安装 InfluxDB

InfluxDB 是一款时序数据库,使用它作为监控数据存储的公司也有很多,可以根据 InfluxDB 官网:https://docs.influxdata.com/influxdb/v1.7/introduction/installation/ 的安装步骤来操作。

1、配置 InfluxDB 下载源。

1
2
3
4
5
6
7
8
cat <<EOF | sudo tee /etc/yum.repos.d/influxdb.repo
[influxdb]
name = InfluxDB Repository - RHEL \$releasever
baseurl = https://repos.influxdata.com/rhel/\$releasever/\$basearch/stable
enabled = 1
gpgcheck = 1
gpgkey = https://repos.influxdata.com/influxdb.key
EOF

2、根据 yum 安装命令操作。

1
yum install influxdb

3、启停 InfluxDB。

1
2
3
4
5
6
7
8
//启动 influxdb 命令
systemctl start influxdb
//重启 influxdb 命令
systemctl restart influxd
//停止 influxdb 命令
systemctl stop influxd
//设置开机自启动
systemctl enable influxdb

4、InfluxDB 相关的命令操作。

启动好 InfluxDB 后执行 influx 命令,然后使用下面命令来创建用户:

1
CREATE USER zhisheng WITH PASSWORD '123456' WITH ALL PRIVILEGES

然后执行 show users; 命令查看创建的用户。

undefined

对 InfluxDB 开启身份验证,编辑 InfluxDB 配置文件 /etc/influxdb/influxdb.conf ,将 auth-enabled 设置为 true。然后重启 InfluxDB,再次使用 influx 命令进入的话,这时候查看用户或者数据的话,就会报异常(需要使用用户名和密码认证登录)。

undefined

这时需要使用下面命令的命令才能够登录:

1
influx -username  zhisheng -password 123456

重新登录就能查询到用户和数据了。

undefined

然后创建一个叫 zhisheng 的数据库,后面会将 Flink 中的监控数据存储到该数据库下。

undefined

安装 Grafana

Grafana 是一款优秀的图表可视化组件,它拥有超多酷炫的图表,并支持自定义配置,用它来做监控的 Dashboard 简直特别完美。

1、下载

1
wget https://dl.grafana.com/oss/release/grafana-6.3.6-1.x86_64.rpm

2、安装

1
yum localinstall grafana-6.3.6-1.x86_64.rpm

undefined

3、启停 Grafana

1
2
3
4
5
6
7
8
//启动 Grafana
systemctl start grafana-server
//停止 Grafana
systemctl stop grafana-server
//重启 Grafana
systemctl restart grafana-server
//设置开机自启动
systemctl enable grafana-server

然后访问 http://54tianzhisheng.cn:3000 就可以登录了。第一次登录的默认账号密码是 admin/admin,会提示修改密码。

配置 Grafana 展示监控数据

登录 Grafana 后,需要配置数据源,Grafana 支持的数据源有很多,比如 InfluxDB、Prometheus 等,选择不同的数据源都可以绘制出很酷炫的图表,如果你公司有使用 Prometheus 做监控系统的,那么可以选择 Prometheus 作为数据源,这里演示就选择 InfluxDB,然后填写 InfluxDB 的地址和用户名密码。

undefined

配置好数据源之后,接下来就是要根据数据源来添加数据图表,因为构造数据图表首先得知道有哪些指标,所以这里先看下分别有哪些指标,这里分 Job Manager、TaskManager 和 Job 三大类。具体有哪些指标其实是可以根据 InfluxDB 里面的 measurements 来查看的,我在 GitHub 放了一份完整的 measurements 列表 以供大家查阅,在 8.1.4 和 8.2.1 节中也都讲解了比较关心的指标,这里展示下如何在 Grafana 中根据这些指标来配置可视化图表。

1、添加图表

undefined

2、配置图表从哪个数据源获取数据、选择哪种指标、选择分组、选择单位、添加多个指标、图表命名

undefined

undefined

undefined

3、配置告警

undefined

这样一个完整的监控图表就配置出来了,有些指标可能直接用数字展示就比较友好,另外还有就是要注意单位,大家可以好好琢磨研究一下 Grafana 的自定义可视化图表的配置,配置好了比较重要的监控指标之后,效果如下图所示:

undefined

undefined

好了,一个 Flink 的监控系统已经完全搭建好了,从数据采集、数据存储、数据展示、告警整个链路都支持,可以适应大部分公司的场景了,如果还需要做更多的定制化,比如添加更多的监控指标,那么你可以在你的 Job 里面自定义 metrics 做埋点,然后还是通过 reporter 进行数据上报,最后依旧用 Grafana 配置图表展示。

小结与反思

本节讲了如何利用 API 去获取监控数据,对 Metrics 的类型进行介绍,然后还介绍了怎么利用 Reporter 去将 Metrics 数据进行上报,并通过 InfluxDB + Grafana 搭建了一套 Flink 的监控系统。另外你还可以根据公司的需要使用其他的存储方案来存储监控数据,Grafana 也支持不同的数据源,你们公司的监控系统架构是怎么样的,是否可以直接接入这套监控系统?

反压(BackPressure)机制被广泛应用到实时流处理系统中,流处理系统需要能优雅地处理反压问题。反压通常产生于这样的场景:短时间的负载高峰导致系统接收数据的速率远高于它处理数据的速率。许多日常问题都会导致反压,例如,垃圾回收停顿可能会导致流入的数据快速堆积,或遇到大促、秒杀活动导致流量陡增。反压如果不能得到正确的处理,可能会导致资源耗尽甚至系统崩溃。反压机制是指系统能够自己检测到被阻塞的 Operator,然后自适应地降低源头或上游数据的发送速率,从而维持整个系统的稳定。

Flink 任务一般运行在多个节点上,数据从上游算子发送到下游算子需要网络传输,若系统在反压时想要降低数据源头或上游算子数据的发送速率,那么肯定也需要网络传输。所以下面先来了解一下 Flink 的网络流控(Flink 对网络数据流量的控制)机制。

下图是一个简单的 Flink 流任务执行图:任务首先从 Kafka 中读取数据、通过 map 算子对数据进行转换、keyBy 按照指定 key 对数据进行分区(key 相同的数据经过 keyBy 后分到同一个 subtask 实例中),keyBy 后对数据进行 map 转换,然后使用 Sink 将数据输出到外部存储。

undefined

众所周知,在大数据处理中,无论是批处理还是流处理,单点处理的性能总是有限的,我们的单个 Job 一般会运行在多个节点上,通过多个节点共同配合来提升整个系统的处理性能。图中,任务被切分成 4 个可独立执行的 subtask 分别是 A0、A1、B0、B1,在数据处理过程中就会存在 shuffle。例如,subtask A0 处理完的数据经过 keyBy 后被发送到 subtask B0、B1 所在节点去处理。那么问题来了,subtask A0 应该以多快的速度向 subtask B0、B1 发送数据呢?把上述问题抽象化,如下图所示,将 subtask A0 当作 Producer,subtask B0 当做 Consumer,上游 Producer 向下游 Consumer 发送数据,在发送端和接收端有相应的 Send Buffer 和 Receive Buffer,但是上游 Producer 生产数据的速率比下游 Consumer 消费数据的速率大,Producer 生产数据的速率为 2MB/s, Consumer 消费数据速率为 1MB/s,Receive Buffer 容量只有 5MB,所以过了 5 秒后,接收端的 Receive Buffer 满了。

undefined

下游消费速率慢,且接收区的 Receive Buffer 有限,如果上游一直有源源不断的数据,那么将会面临着以下两种情况:

  1. 下游消费者的缓冲区放不下数据,导致下游消费者会丢弃新到达的数据。
  2. 为了不丢弃数据,所以下游消费者的 Receive Buffer 持续扩张,最后耗尽消费者的内存,导致 OOM 程序挂掉。

常识告诉我们,这两种情况在生产环境下都是不能接受的,第一种会丢数据、第二种会把应用程序挂掉。所以,该问题的解决方案不应该是下游 Receive Buffer 一直累积数据,而是上游 Producer 发现下游 Consumer 消费比较慢的时候,应该在 Producer 端做出限流的策略,防止在下游 Consumer 端无限制地堆积数据。那上游 Producer 端该如何做限流呢?可以采用下图所示静态限流的策略:

undefined

静态限速的思想就是,提前已知下游 Consumer 端的消费速率,然后在上游 Producer 端使用类似令牌桶的思想,限制 Producer 端生产数据的速率,从而控制上游 Producer 端向下游 Consumer 端发送数据的速率。但是静态限速会存在问题:

  1. 通常无法事先预估下游 Consumer 端能承受的最大速率。
  2. 就算通过某种方式预估出下游 Consumer 端能承受的最大速率,在运行过程中也可能会因为网络抖动、CPU 共享竞争、内存紧张、IO阻塞等原因造成下游 Consumer 的吞吐量降低,但是上游 Producer 的吞吐量正常,然后又会出现之前所说的下游接收区的 Receive Buffer 有限,上游一直有源源不断的数据发送到下游的问题,还是会造成下游要么丢数据,要么为了不丢数据 buffer 不断扩充导致下游 OOM 的问题。

综上所述,我们发现了,上游 Producer 端必须有一个限流的策略,且静态限流是不可靠的,于是就需要一个动态限流的策略。可以采用下图所示的动态反馈策略:

undefined

下游 Consumer 端会频繁地向上游 Producer 端进行动态反馈,告诉 Producer 下游 Consumer 的负载能力,从而使 Producer 端可以动态调整向下游 Consumer 发送数据的速率,以实现 Producer 端的动态限流。当 Consumer 端处理较慢时,Consumer 将负载反馈到 Producer 端,Producer 端会根据反馈适当降低 Producer 自身从上游或者 Source 端读数据的速率来降低向下游 Consumer 发送数据的速率。当 Consumer 处理负载能力提升后,又及时向 Producer 端反馈,Producer 会通过提升自身从上游或 Source 端读数据的速率来提升向下游发送数据的速率,通过动态反馈的策略来动态调整系统整体的吞吐量。

读到这里,应该知道 Flink 为什么需要网络流控机制了,并且知道 Flink 的网络流控机制必须是一个动态反馈的策略。但是还有以下几个问题:

  1. Flink 中数据具体是怎么从上游 Producer 端发送到下游 Consumer 端的?
  2. Flink 的动态限流具体是怎么实现的?下游的负载能力和压力是如何传递给上游的?

带着这两个问题,学习下面的 Flink 网络流控与反压机制。

在 Flink 1.5 之前,Flink 没有使用任何复杂的机制来解决反压问题,因为根本不需要那样的方案!Flink 利用自身作为纯数据流引擎的优势来优雅地响应反压问题。下面我们会深入分析 Flink 是如何在 Task 之间传输数据的,以及数据流如何实现自然降速的。

如下图所示,Job 分为 Task A、B、C,Task A 是 Source Task、Task B 处理转换数据、Task C 是 Sink Task。

  • Task A 从外部 Source 端读取到数据后将数据序列化放到 Send Buffer 中,再由 Task A 的 Send Buffer 发送到 Task B 的 Receive Buffer;

  • Task B 的算子从 Task B 的 Receive Buffer 中将数据反序列后进行处理,将处理后数据序列化放到 Task B 的 Send Buffer 中,再由 Task B 的 Send Buffer 发送到 Task C 的 Receive Buffer;

  • Task C 再从 Task C 的 Receive Buffer 中将数据反序列后输出到外部 Sink 端,这就是所有数据的传输和处理流程。

  • undefined

  • Flink 中,动态反馈策略原理比较简单,假如 Task C 由于各种原因吞吐量急剧降低,那么肯定会造成 Task C 的 Receive Buffer 中堆积大量数据,此时 Task B 还在给 Task C 发送数据,但是毕竟内存是有限的,持续一段时间后 Task C 的 Receive Buffer 满了,此时 Task B 发现 Task C 的 Receive Buffer 满了后,就不会再往 Task C 发送数据了,Task B 处理完的数据就开始往 Task B 的 Send Buffer 积压,一段时间后 Task B 的 Send Buffer 也满了,Task B 的处理就会被阻塞,这时 Task A 还在往 Task B 的 Receive Buffer 发送数据。

    同样的道理,Task B 的 Receive Buffer 很快满了,导致 Task A 不再往 Task B 发送数据,Task A 的 Send Buffer 也会被用完,Task A 是 Source Task 没有上游,所以 Task A 直接降低从外部 Source 端读取数据的速率甚至完全停止读取数据。

    通过以上原理,Flink 将下游的压力传递给上游。

    如果下游 Task C 的负载能力恢复后,如何将负载提升的信息反馈给上游呢?

    实际上 Task B 会一直向 Task C 发送探测信号,检测 Task C 的 Receive Buffer 是否有足够的空间,当 Task C 的负载能力恢复后,Task C 会优先消费 Task C Receive Buffer 中的数据,Task C Receive Buffer 中有足够的空间时,Task B 会从 Send Buffer 继续发送数据到 Task C 的 Receive Buffer,Task B 的 Send Buffer 有足够空间后,Task B 又开始正常处理数据,很快 Task B 的 Receive Buffer 中也会有足够空间,同理,Task A 会从 Send Buffer 继续发送数据到 Task B 的 Receive Buffer,Task A 的 Receive Buffer 有足够空间后,Task A 就可以从外部的 Source 端开始正常读取数据了。

    通过以上原理,Flink 将下游负载过低的消息传递给上游。所以说 Flink 利用自身纯数据流引擎的优势优雅地响应反压问题,并没有任何复杂的机制来解决反压。上述流程,就是 Flink 动态限流(反压机制)的简单描述,可以看到 Flink 的反压是从下游往上游传播的,一直往上传播到 Source Task 后,Source Task 最终会降低或提升从外部 Source 端读取数据的速率。

    如下图所示,对于一个 Flink 任务,动态反馈要考虑如下两种情况:

    \1. 跨 Task,动态反馈具体如何从下游 Task 的 Receive Buffer 反馈给上游 Task 的 Send Buffer。

    • 当下游 Task C 的 Receive Buffer 满了,如何告诉上游 Task B 应该降低数据发送速率;
    • 当下游 Task C 的 Receive Buffer 空了,如何告诉上游 Task B 应该提升数据发送速率。

    注:这里又分了两种情况,Task B 和 Task C 可能在同一个 TaskManager 上运行,也有可能不在同一个 TaskManager 上运行。

    1. Task B 和 Task C 在同一个 TaskManager 运行指的是:一个 TaskManager 包含了多个 Slot,Task B 和 Task C 都运行在这个 TaskManager 上。此时 Task B 给 Task C 发送数据实际上是同一个 JVM 内的数据发送,所以不存在网络通信
    2. Task B 和 Task C 不在同一个 TaskManager 运行指的是:Task B 和 Task C 运行在不同的 TaskManager 中。此时 Task B 给 Task C 发送数据是跨节点的,所以会存在网络通信

    \2. Task 内,动态反馈如何从内部的 Send Buffer 反馈给内部的 Receive Buffer。

    • 当 Task B 的 Send Buffer 满了,如何告诉 Task B 内部的 Receive Buffer,自身的 Send Buffer 已经满了?要让 Task B 的 Receive Buffer 感受到压力,才能把下游的压力传递到 Task A。
    • 当 Task B 的 Send Buffer 空了,如何告诉 Task B 内部的 Receive Buffer 下游 Send Buffer 空了,并把下游负载很低的消息传递给 Task A。

    undefined

    到目前为止,动态反馈的具体细节抽象成了三个问题:

    • 跨 Task 且 Task 不在同一个 TaskManager 内,动态反馈具体如何从下游 Task 的 Receive Buffer 反馈给上游 Task 的 Send Buffer;
    • 跨 Task 且 Task 在同一个 TaskManager 内,动态反馈具体如何从下游 Task 的 Receive Buffer 反馈给上游 Task 的 Send Buffer;
    • Task 内,动态反馈具体如何从 Task 内部的 Send Buffer 反馈给内部的 Receive Buffer。

    TaskManager 之间网络传输相关组件

    如下图所示,是 TaskManager 之间数据传输流向,可以看到:

    • Source Task 给 Task B 发送数据,Source Task 做为 Producer,Task B 做为 Consumer,Producer 端产生的数据最后通过网络发送给 Consumer 端。
    • Producer 端 Operator 实例对一条条的数据进行处理,处理完的数据首先缓存到 ResultPartition 内的 ResultSubPartition 中。
    • ResultSubPartition 中一个 Buffer 写满或者超时后,就会触发将 ResultSubPartition 中的数据拷贝到 Producer 端 Netty 的 Buffer 中,之后又把数据拷贝到 Socket 的 Send Buffer 中,这里有一个从用户态拷贝到内核态的过程,最后通过 Socket 发送网络请求,把 Send Buffer 中的数据发送到 Consumer 端的 Receive Buffer。
    • 数据到达 Consumer 端后,再依次从 Socket 的 Receive Buffer 拷贝到 Netty 的 Buffer,再拷贝到 Consumer Operator InputGate 内的 InputChannel 中,最后 Consumer Operator 就可以读到数据进行处理了。

    这就是两个 TaskManager 之间的数据传输过程,我们可以看到发送方和接收方各有三层的 Buffer。当 Task B 往下游发送数据时,整个流程与 Source Task 给 Task B 发送数据的流程类似。

    undefined

    根据上述流程,下表中对 Flink 通信相关的一些术语进行介绍:

    概念/术语 解释
    ResultPartition 生产者生产的数据首先写入到 ResultPartition 中,一个 Operator 实例对应一个ResultPartition。
    ResultSubpartition 一个 ResultPartition 是由多个 ResultSubpartition 组成。当 Producer Operator 实例生产的数据要发送给下游 Consumer Operator n 个实例时,那么该 Producer Operator 实例对应的 ResultPartition 中就包含 n 个 ResultSubpartition。
    InputGate 消费者消费的数据来自于 InputGate 中,一个 Operator 实例对应一个InputGate。网络中传输的数据会写入到 Task 的 InputGate。
    InputChannel 一个 InputGate 是由多个 InputChannel 组成。当 Consumer Operator 实例读取的数据来自于上游 Producer Operator n 个实例时,那么该 Consumer Operator 实例对应的 InputGate 中就包含 n 个 InputChannel。
    RecordReader 用于将记录从Buffer中读出。
    RecordWriter 用于将记录写入Buffer。
    LocalBufferPool 为 ResultPartition 或 InputGate 分配内存,每一个 ResultPartition 或 InputGate分别对应一个 LocalBufferPool。
    NetworkBufferPool 为 LocalBufferPool 分配内存,NetworkBufferPool 是 Task 之间共享的,每个 TaskManager 只会实例化一个。

    InputGate 和 ResultPartition 的内存是如何申请的呢?如下图所示,了解一下 Flink 网络传输相关的内存管理。

    • 在 TaskManager 初始化时,Flink 会在 NetworkBufferPool 中生成一定数量的内存块 MemorySegment,内存块的总数量就代表了网络传输中所有可用的内存。
    • NetworkBufferPool 是 Task 之间共享的,每个 TaskManager 只会实例化一个。
    • Task 线程启动时,会为 Task 的 InputChannel 和 ResultSubPartition 分别创建一个 LocalBufferPool。InputGate 或 ResultPartition 需要写入数据时,会向相对应的 LocalBufferPool 申请内存(图中①),当 LocalBufferPool 没有足够的内存且还没到达 LocalBufferPool 设置的上限时,就会向 NetworkBufferPool 申请内存(图中②),并将内存分配给相应的 InputChannel 或 ResultSubPartition(图③④)。
    • 虽然可以申请,但是必须明白内存申请肯定是有限制的,不可能无限制的申请,我们在启动任务时可以指定该任务最多可能申请多大的内存空间用于 NetworkBufferPool。
    • 当 InputChannel 的内存块被 Operator 读取消费掉或 ResultSubPartition 的内存块已经被写入到了 Netty 中,那么 InputChannel 和 ResultSubPartition 中的内存块就可以还给 LocalBufferPool 了(图中⑤),如果 LocalBufferPool 中有较多空闲的内存块,就会还给 NetworkBufferPool (图中⑥)。

    undefined

    了解了 Flink 网络传输相关的内存管理,我们来分析 3 种动态反馈的具体细节。

    跨 Task 且 Task 不在同一个 TaskManager 内时,反压如何向上游传播

    如下图所示,Producer 端生产数据速率为 2MB/s,Consumer 消费数据速率为 1MB/s。持续下去,下游消费较慢,Buffer 容量又是有限的,那 Flink 反压是怎么做的?

    undefined

    数据从 Task A 的 ResultSubPartition 按照上面的流程最后传输到 Task B 的 InputChannel 供 Task B 读取并计算。持续一段时间后,由于 Task B 消费比较慢,导致 InputChannel 被占满了,所以 InputChannel 向 LocalBufferPool 申请新的 Buffer 空间,LocalBufferPool 分配给 InputChannel 一些 Buffer。

    再持续一段时间后,InputChannel 重复向 LocalBufferPool 申请 Buffer 空间,导致 LocalBufferPool 内的 Buffer 空间被用完了,所以 LocalBufferPool 向 NetWorkBufferPool 申请 Buffer 空间,NetWorkBufferPool 给 LocalBufferPool 分配 Buffer。再持续下去,NetWorkBufferPool 也用完了,或者说 NetWorkBufferPool 不能把自己的 Buffer 全分配给 Task B 对应的 LocalBufferPool,因为 TaskManager 上一般会运行了多个 Task,每个 Task 只能使用 NetWorkBufferPool 中的一部分。

    此时可以认为 Task B 把自己可以使用的 LocalBufferPool 和 NetWorkBufferPool 都用完了。此时 Netty 还想把数据写入到 InputChannel,但是发现 InputChannel 满了,所以 Socket 层会把 Netty 的 autoRead disable,Netty 不会再从 Socket 中去读消息。由于 Netty 不从 Socket 的 Receive Buffer 读数据了,所以很快 Socket 的 Receive Buffer 就会变满,TCP 的 Socket 通信有动态反馈的流控机制,会把下游容量为 0 的消息反馈给上游发送端,所以上游的 Socket 就不会往下游再发送数据。

    可以看到下图中 Consumer 端多个通道显示 ❌,表示该通道所能提供的内存已经被申请完,数据已经不能往下游写了,发生了阻塞。

    undefined

此时 Task A 持续生产数据,发送端 Socket 的 Send Buffer 很快被打满,所以 Task A 端的 Netty 也会停止往 Socket 写数据。数据会在 Netty 的 Buffer 中缓存数据,Netty 的 Buffer 是无界的,可以设置 Netty 的高水位,即:设置一个 Netty 中 Buffer 的上限。

所以每次 ResultSubPartition 向 Netty 中写数据时,都会检测 Netty 是否已经到达高水位,如果达到高水位就不会再往 Netty 中写数据,防止 Netty 的 Buffer 无限制的增长。接下来,数据会在 Task A 的 ResultSubPartition 中累积,ResultSubPartition 数据写满后,会向 LocalBufferPool 申请新的 Buffer 空间,LocalBufferPool 分配给 ResultSubPartition 一些 Buffer。

持续下去 LocalBufferPool 也会用完,LocalBufferPool 再向 NetWorkBufferPool 申请 Buffer。NetWorkBufferPool 也会被用完,或者说 NetWorkBufferPool 不能把自己的 Buffer 全分配给 Task A 对应的 LocalBufferPool,因为 TaskManager 上一般会运行了多个 Task,每个 Task 只能使用 NetWork BufferPool 中的一部分。此时,Task A 已经申请不到任何的 Buffer 了,Task A 的 Record Writer 输出就被 wait,Task A 不再生产数据。如下图所示,Producer 和 Consumer 端所有的通道都被阻塞。

当下游 Task B 持续消费,Task B 的 InputChannel 中部分的 Buffer 可以被回收,所有被阻塞的数据通道会被一个个打开,之后 Task A 又可以开始正常的生产数据了。通过上述的整个流程,来动态反馈,保障各个 Buffer 都不会因为数据太多导致内存溢出。

跨 Task 且 Task 在同一个 TaskManager 内,反压如何向上游传播

一般情况下,一个 TaskManager 内会运行多个 slot,每个 slot 内运行一个 SubTask。所以,Task 之间的数据传输可能存在上游的 Task A 和下游的 Task B 运行在同一个 TaskManager 的情况,整个数据传输流程与上述类似,只不过由于 Task A 和 B 运行在同一个 JVM,所以不需要网络传输的环节,Task A 会将 Buffer 直接交给 Task B,一旦 Task B 消费了该 Buffer,则该 Buffer 就会被 Task A ResultSubPartition 对应的 LocalBufferPool 回收。

如果 Task B 消费的速度一直比 Task A 生产的速度慢,持续下去就会导致 Task A 申请不到 LocalBufferPool,最终造成 Task A 生产数据被阻塞。当下游 Task B 消费速度恢复后,Task A 就可以回收 ResultSubPartition 对应的已经被 Task B 消费的 Buffer,Task A 又可以正常的开始生产数据了,通过上述流程,来实现跨 Task 且 Task 在同一个 TaskManager 内的动态反馈。

Task 内部,反压如何向上游传播

假如 Task A 的下游所有 Buffer 都占满了,那么 Task A 的 Record Writer 会被 block,Task A 的 Record Reader、Operator、Record Writer 都属于同一个线程,Task A 的 Record Reader 也会被 block。

这里分为两种情况,假如 Task A 是 Source Task,那么 Task A 就不会从外部的 Source 端读取数据,假如 Task A 还有上游的 Task,那么 Task A 就不会从自身的 InputChannel 中读取数据,然后又通过第一种动态反馈策略,将 Task A 的压力反馈给 Task A 的上游 Task。

当 Task A 的下游消费恢复后,ResultSubPartition 就可以申请到 Buffer,Task A 的 Record Writer 就不会被 block,Task A 就可以恢复正常的消费。通过上述流程,来实现 Task 内部的动态反馈。

通过以上三种情况的分析,得出的结论:Flink 1.5 之前并没有特殊的机制来处理反压,因为 Flink 中的数据传输相当于已经提供了应对反压的机制。

基于 Credit 的反压机制

1.5 之前反压机制存在的问题

看似完美的反压机制,其实是有问题的。

如下图所示,我们的任务有 4 个 SubTask,SubTask A 是 SubTask B的上游,即 SubTask A 给 SubTask B 发送数据。Job 运行在两个 TaskManager中,TaskManager 1 运行着 SubTask A1 和 SubTask A2,TaskManager 2 运行着 SubTask B1 和 SubTask B2。假如 SubTask B2 遇到瓶颈、处理速率有所下降,上游源源不断地生产数据,最后导致 SubTask A2 与 SubTask B2 产生反压。虽然此时 SubTask B1 没有压力,但是发现在 SubTask A1 和 A2 中都积压了很多 SubTask B1 的数据。本来只是 SubTask B2 遇到瓶颈了,但是也影响到 SubTask B1 的正常处理,为什么呢?

undefined

这里需要明确一点:不同 Job 之间的每个(远程)网络连接将在 Flink 的网络堆栈中获得自己的TCP通道。但是,如果同一 Task 的不同 SubTask 被安排到同一个 TaskManager,则它们与其他 TaskManager 的网络连接将被多路复用并共享一个TCP信道以减少资源使用。图中的 SubTask A1 和 A2 是 Task A 不同并行度的实例,且安排到同一个 TaskManager 内部,所以 SubTask A1 和 A2 与其他 TaskManager 进行网络数据传输时共享同一个 TCP 信道。同理,SubTask B1 和 B2 与其他 TaskManager 进行网络数据传输时也共享同一个 TCP 信道。所以,图中所示的 A1 → B1、A1 → B2、A2 → B1、A2 → B2 这四个网络连接将会多路复用共享一个 TCP 信道。

从上面跨 TaskManager 的反压流程,我们知道现在 SubTask B1 没有压力,根据跨 TaskManager 之间的动态反馈(反压)原理,当 SubTask A2 与 SubTask B2 产生反压时,会把 TaskManager1 端任务对应 Socket 的 Send Buffer 和 TaskManager2 端该任务对应 Socket 的 Receive Buffer 占满,也就是说多路复用的 TCP 通道被完全阻塞了或者整个 TCP 通道的传输速率大大降低了,导致 SubTask A1 和 SubTask A2 发送给 SubTask B1 的数据被阻塞了,使得本来没有压力的 SubTask B1 现在也接收不到数据了。所以,Flink 1.5 之前的反压机制会存在当一个 SubTask 出现反压时,可能导致其他正常的 SubTask 也接收不到数据。

基于 Credit 的反压机制原理

为了解决上述所描述的问题,Flink 1.5 之后的版本,引入了基于 Credit 的反压机制。如下图所示,反压机制直接作用于 Flink 的应用层,即在 ResultSubPartition 和 InputChannel 这一层引入了反压机制。基于 Credit 的流量控制可确保发送端已经发送的任何数据,接收端都具有足够的 Buffer 来接收。上游 SubTask 给下游 SubTask 发送数据时,会把 Buffer 中要发送的数据和上游 ResultSubPartition 堆积的数据量 Backlog size 发给下游,下游接收到上游发来的 Backlog size 后,会向上游反馈现在的 Credit 值,Credit 值表示目前下游可以接收上游的 Buffer 量,1 个Buffer 等价于 1 个 Credit。上游接收到下游反馈的 Credit 值后,上游下次最多只会发送 Credit 个数据到下游,保障不会有数据积压在 Socket 这一层。

Flink 1.5 之前一个 Operator 实例对应一个InputGate,每个 InputGate 的多个 InputChannel 共用一个 LocalBufferPool。Flink 1.5 之后每个 Operator 实例的每个远程输入通道(Remote InputChannel)现在都有自己的一组独占缓冲区(Exclusive Buffer),而不是只有一个共享的 LocalBufferPool。与之前不同,LocalBufferPool 的缓冲区称为流动缓冲区(Floating buffers),每个 Operator 对应一个 Floating buffers,Floating buffers 内的 buffer 会在 InputChannel 间流动并且可用于每个 InputChannel。

undefined

如上图所示,上游 SubTask A2 发送完数据后,还有 4 个 Buffer 被积压,会把要发送的 Buffer 数据和 Backlog size = 4 一块发送给下游 SubTask B2,下游接受到数据后,知道上游积压了 4 个Buffer 的数据,于是向 Buffer Pool 申请 Buffer,申请完成后由于容量有限,下游 InputChannel 目前仅有 2 个 Buffer 空间,所以,SubTask B2 会向上游 SubTask A2 反馈 Channel Credit = 2,上游就知道了下游目前最多只能承载 2 个 Buffer 的数据。

所以下一次上游给上游发送数据时,最多只给下游发送 2 个 Buffer 的数据。当下游 SubTask 反压比较严重时,可能就会向上游反馈 Channel Credit = 0,此时上游就知道下游目前对应的 InputChannel 没有可用空间了,所以就不向下游发送数据了。

此时,上游还会定期向下游发送探测信号,检测下游返回的 Credit 是否大于 0,当下游返回的 Credit 大于 0 表示下游有可用的 Buffer 空间,上游就可以开始向下游发送数据了。

通过这种基于 Credit 的反馈策略,就可以保证每次上游发送的数据都是下游 InputChannel 可以承受的数据量,所以在公用的 TCP 这一层就不会产生数据堆积而影响其他 SubTask 通信。基于 Credit 的反压机制还带来了一个优势:由于我们在发送方和接收方之间缓存较少的数据,可能会更早地将反压反馈给上游,缓冲更多数据只是把数据缓冲在内存中,并没有提高处理性能。

反压监控原理介绍

Flink 的反压太过于天然了,导致无法简单地通过监控 BufferPool 的使用情况来判断反压状态。Flink 通过对运行中的任务进行采样来确定其反压,如果一个 Task 因为反压导致处理速度降低了,那么它肯定会卡在向 LocalBufferPool 申请内存块上。那么该 Task 的 stack trace 应该是这样:

1
2
3
4
java.lang.Object.wait(Native Method)
o.a.f.[...].LocalBufferPool.requestBuffer(LocalBufferPool.java:163)
o.a.f.[...].LocalBufferPool.requestBufferBlocking(LocalBufferPool.java:133) <--- BLOCKING request
[...]

Flink 的反压监控就是依赖上述原理,通过不断对每个 Task 的 stack trace 采样来进行反压监控。由于反压监控对正常的任务运行有一定影响,因此只有当 Web 页面切换到 Job 的 BackPressure 页面时,JobManager 才会对该 Job 触发反压监控。

默认情况下,JobManager 会触发 100 次 stack trace 采样,每次间隔 50ms 来确定反压。Web 界面看到的比率表示在内部方法调用中有多少 stack trace 被卡在 LocalBufferPool.requestBufferBlocking(),例如:0.01 表示在 100 个采样中只有 1 个被卡在 LocalBufferPool.requestBufferBlocking()。采样得到的比例与反压状态的对应关系如下:

  • OK:0 <= 比例 <= 0.10
  • LOW:0.10 < 比例 <= 0.5
  • HIGH:0.5 < 比例 <= 1

Task 的状态为 OK 表示没有反压,HIGH 表示这个 Task 被反压。

如下图所示,表示 Flink Web UI 中 BackPressure 选项卡,可以查看任务中 subtask 的反压状态。

undefined

undefined

如果看到一个 Task 发生反压警告(例如:High),意味着它生产数据的速率比下游 Task 消费数据的速率要快。在工作流中数据记录是从上游向下游流动的(例如:从 Source 到 Sink)。反压沿着相反的方向传播,沿着数据流向上游传播。以一个简单的 Source -> Sink Job 为例。如果看到 Source 发生了警告,意味着 Sink 消费数据的速率比 Source 生产数据的速率要慢,Sink 的吞吐量降低了,Sink 正在向上游的 Source 算子产生反压。应该找 Sink 出了什么问题导致反压,而不是找 Source 出了什么问题。

假如一个 Job 由 Task A、B、C 组成,数据流向是 A → B → C,当看到 Task A、B 的反压状态为 HIGH,Task C 的反压状态为 OK 时,实际上是 C 的吞吐量较低导致的,为什么呢?从实现原理来讲,当 Task C 吞吐量较低时,Task C 会产生反压且 InputChannel 所有可以申请的 Buffer 已经占满了,Task C 会给上游 Task B 返回 Credit = 0,导致 Task B 的数据发送不到 Task C,Task C 此时不需要申请 Buffer 空间,所以 Task C 的 stack trace 不会卡在 LocalBufferPool.requestBufferBlocking(),Task C 此时在处理那些 InputChannel 中待处理的数据。再来分析 Task B,Task B 此时正在处理数据,需要将处理完的数据输出到 ResultSubPartition,但此时 ResultSubPartition 在 LocalBufferPool 申请不到空闲的 Buffer 空间,所以 Task B 会卡在 LocalBufferPool.requestBufferBlocking() 这一步等待申请 Buffer 空间。同理可得,当 Task C 反压比较严重时,Task B 上游的 Task A 也会卡在 LocalBufferPool.requestBufferBlocking()。得出结论:当 Flink 的某个 Task 出现故障导致吞吐量严重下降时,在 Flink 的反压页面,我们会看到该 Task 的反压状态为 OK,而该 Task 上游所有 Task 的反压状态为 HIGH。所以,我们根据 Flink 的 BackPressure 页面去定位哪个 Task 出故障时,首先要找到反压状态为 HIGH 的最后一个 Task,该 Task 紧跟的下一个 Task 就是我们要找的有故障的 Task。

如下图所示是一个 Job 的执行计划图,任务被切分为三个 Operator 分别是 Source、FlatMap、Sink。当看到 Source 和 FlatMap 的 BackPressure 页面都显示 HIGH,Sink 的 BackPressure 页面显示 OK 时,意思是任务产生了反压,且反压的根源是 Sink,也就是说 Sink 算子目前遇到了性能瓶颈吞吐量较低,下一步就应该定位什么原因导致 Sink 算子吞吐量降低。

undefined

当集群中网络 IO 遇到瓶颈时也可能会导致 Job 产生反压,假设有两个 Task A 和 B,Task A 是 Task B 的上游,若 Task B 的吞吐量很高,但是由于网络瓶颈,造成 Task A 的数据不能快速的发送给 Task B,所以导致上游 Task A 被反压了。此时在反压监控页面也会看到 Task A 的反压状态为 HIGH、Task B 的反压状态为 OK,但实际上并不是 Task B 遇到了瓶颈。像这种网络遇到瓶颈的情况应该比较少见,但大家要清楚可能会出现,如果发现 Task B 没有任何瓶颈时,要注意查看是不是网络瓶颈导致。

如下图所示是一个多 Sink 任务流的执行计划,任务被切分为四个 Operator 分别是 Source、FlatMap、HBase Sink 和 Redis Sink。当看到 Source 和 FlatMap 的 BackPressure 页面都显示 HIGH,HBase Sink 和 Redis Sink 的 BackPressure 页面显示 OK 时,意思是任务产生了反压,且反压的根源是 Sink,但此时无法判断到底是 HBase Sink 还是 Redis Sink 出现了故障。这种情况,该如何来定位反压的来源呢?来学习我们下一部分利用 Flink Metrics 定位产生反压的位置。

undefined

当某个 Task 吞吐量下降时,基于 Credit 的反压机制,上游不会给该 Task 发送数据,所以该 Task 不会频繁卡在向 Buffer Pool 去申请 Buffer。反压监控实现原理就是监控 Task 是否卡在申请 buffer 这一步,所以遇到瓶颈的 Task 对应的反压页面必然会显示 OK,即表示没有受到反压。如果该 Task 吞吐量下降,造成该 Task 上游的 Task 出现反压时,必然会存在:该 Task 对应的 InputChannel 变满,已经申请不到可用的 Buffer 空间。

如果该 Task 的 InputChannel 还能申请到可用 Buffer,那么上游就可以给该 Task 发送数据,上游 Task 也就不会被反压了,所以说遇到瓶颈且导致上游 Task 受到反压的 Task 对应的 InputChannel 必然是满的(这里不考虑网络遇到瓶颈的情况)。

从这个思路出发,可以对该 Task 的 InputChannel 的使用情况进行监控,如果 InputChannel 使用率 100%,那么该 Task 就是我们要找的反压源。Flink Metrics 提供了 inputExclusiveBuffersUsage、 inputFloatingBuffersUsage、inPoolUsage 等参数可以帮助我们来监控 InputChannel 的 Buffer 使用情况。

参数 解释
inputFloatingBuffersUsage 每个 Operator 实例对应一个 FloatingBuffers,inputFloatingBuffersUsage 表示 Operator 对应的 FloatingBuffers 使用率
inputExclusiveBuffersUsage 每个 Operator 实例的每个远程输入通道(Remote InputChannel)都有自己的一组独占缓冲区(ExclusiveBuffer),inputExclusiveBuffersUsage 表示 ExclusiveBuffer 的使用率
inPoolUsage Flink 1.5 - 1.8 中的 inPoolUsage 表示 inputFloatingBuffersUsage。Flink 1.9 及以上版本 inPoolUsage 表示 inputFloatingBuffersUsage 和 inputExclusiveBuffersUsage 的总和

Flink 输入 BufferPool 相关的 Metrics 还有 inputQueueLength 指标,类似于 inPoolUsage,但是 inputQueueLength 表示的是 Buffer 使用的个数,而 inPoolUsage 表示的使用率。有时候我们看到 buffer 使用的个数并不知道其是否压力大,因为我们没有拿到 buffer 的总数量,所以使用率会更直观,强烈建议使用 inPoolUsage。

上图案例中,若无法判断是 Redis Sink 还是 HBase Sink 吞吐量降低导致 Job 反压,只需要在 Flink Web UI 的 Metrics 页面分别查看两个 Task 的 inputExclusiveBuffersUsage、 inputFloatingBuffersUsage、inPoolUsage 参数,我们就可以定位到反压源。

定位反压源的流程首先通过 Flink Web UI 来定位,如果定位不到再通过 Metrics 来辅助我们精确定位。但上述几个 Metrics 参数仅适用于网络传输的情况,当任务执行过程中不存在数据网络传输时,就不存在 InputChannel 变满的情况,此时也无法通过 Metrics 来定位反压源,可以凭借开发者的经验或者改动代码、删掉可能是瓶颈的算子然后发布看处理性能是否提升来定位反压源。

定位到反压来源后,该如何处理?

假设确定了反压源(瓶颈)的位置,下一步就是分析为什么会发生这种情况。下面,列出了从最基本到比较复杂的一些反压潜在原因。建议先检查基本原因,然后再深入研究更复杂的原因,最后找出导致瓶颈的原因。

还请记住,反压可能是暂时的,可能是由于负载高峰、CheckPoint 或作业重启引起的数据积压而导致反压。如果反压是暂时的,应该忽略它。另外,请记住,断断续续的反压会影响我们分析和解决问题。话虽如此,以下几点需要检查。

系统资源

首先,应该检查涉及服务器基本资源的使用情况,如 CPU、网络或磁盘 I/O。如果某些资源被充分利用或大量使用,则可以执行以下操作之一:

  1. 尝试优化代码。代码分析器在这种情况下很有用。
  2. 针对特定的资源调优 Flink。
  3. 通过增加并行度或增加群集中的服务器数量来横向扩展。
  4. 减少瓶颈算子上游的并行度,从而减少瓶颈算子接受的数据量。这个方案虽然可以使得瓶颈算子压力减少,但是不建议,可能会造成整个 Job 的数据延迟增大。

垃圾收集(GC)

通常,长时间GC暂停会导致性能问题。您可以通过打印调试GC日志(通过 -XX:+PrintGCDetails)或使用某些内存或 GC 分析器来验证是否处于这种情况。由于处理 GC 问题高度依赖于应用程序,独立于 Flink,因此不会在此详细介绍。

CPU/线程瓶颈

有时,一个或几个线程导致 CPU 瓶颈,而整个机器的 CPU 使用率仍然相对较低,则可能无法看到 CPU 瓶颈。例如,48 核的服务器上,单个 CPU 瓶颈的线程仅占用 2% 的 CPU 使用率,就算单个线程发生了 CPU 瓶颈,我们也看不出来。可以考虑使用代码分析器,它们可以显示每个线程的 CPU 使用情况来识别热线程。

线程竞争

与上面的 CPU/线程瓶颈问题类似,subtask 可能会因为共享资源上高负载线程的竞争而成为瓶颈。同样,CPU 分析器是你最好的朋友!考虑在用户代码中查找同步开销、锁竞争,尽管避免在用户代码中添加同步。

负载不平衡

如果瓶颈是由数据倾斜引起的,可以尝试通过将数据分区的 key 进行加盐或通过实现本地预聚合来减轻数据倾斜的影响。关于数据倾斜的详细解决方案,会在第 9.6 节详细讨论。

外部依赖

如果发现我们的 Source 端数据读取性能比较低或者 Sink 端写入性能较差,需要检查第三方组件是否遇到瓶颈,例如,Kafka 集群是否需要扩容,Kafka 连接器是否并行度较低,HBase 的 rowkey 是否遇到热点问题。关于第三方组件的性能问题,需要结合具体的组件来分析,这里不进行详细介绍。

以上情况并非很详细。通常,为了解决瓶颈和减小反压,首先要分析反压发生的位置,然后找出原因并解决。

小结与反思

本节详细介绍 Flink 的反压机制,首先讲述了 Flink 为什么需要网络流控机制,再介绍了 Flink 1.5 之前的网络流控机制以及存在的问题,从而引出了基于 Credit 的反压机制,最后讲述 Flink 如何定位产生反压的位置以及定位到反压源后该如何处理。某个 Flink Job 中从 Source 到 Sink 的所有算子都不产生 shuffle,当任务的吞吐量降低时,无论是通过 Flink WebUI 还是 Metrics 都无法来辅助定位任务的瓶颈,对于这种情况,如何来定位任务的瓶颈,并解决呢?